Xây dựng ứng dụng full-stack với Nuxt 3: Server Routes, Nitro Engine và Drizzle ORM

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Sau 6 tháng chạy Nuxt 3 trên production cho một SaaS nhỏ, mình có thể nói thẳng: đây là stack mà mình ước mình biết sớm hơn. Không cần tách backend riêng, không cần cấu hình CORS phức tạp, không cần maintain hai repo. Một project, một deploy, xong.

Bài này mình sẽ đi thẳng vào thực tế — setup nhanh trong 5 phút rồi giải thích tại sao nó hoạt động.

Quick Start: Chạy được trong 5 phút

Tạo project mới và cài dependencies:

npx nuxi@latest init my-fullstack-app
cd my-fullstack-app
npm install drizzle-orm better-sqlite3
npm install -D drizzle-kit @types/better-sqlite3

Tạo file schema cho database tại server/db/schema.ts:

import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'

export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  title: text('title').notNull(),
  content: text('content').notNull(),
  createdAt: integer('created_at', { mode: 'timestamp' })
    .$defaultFn(() => new Date())
})

Tạo database client tại server/db/index.ts:

import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import * as schema from './schema'

const sqlite = new Database('sqlite.db')
export const db = drizzle(sqlite, { schema })

Tạo API route đầu tiên tại server/api/posts/index.get.ts:

import { db } from '~/server/db'
import { posts } from '~/server/db/schema'

export default defineEventHandler(async () => {
  return await db.select().from(posts).all()
})

Chạy migration và khởi động:

npx drizzle-kit push
npm run dev

Vào http://localhost:3000/api/posts — bạn đã có API chạy rồi. Không cần Express, không cần Fastify riêng.

Nitro Engine: Tại sao nó khác với Express?

Nitro là runtime server mà Nuxt 3 dùng bên dưới. Điểm khác biệt lớn nhất so với Express hay Fastify truyền thống là file-based routing — giống Next.js nhưng phía server.

Cấu trúc thư mục server/api/ tự động map thành routes:

server/api/
├── posts/
│   ├── index.get.ts      → GET  /api/posts
│   ├── index.post.ts     → POST /api/posts
│   └── [id].get.ts       → GET  /api/posts/:id
└── users/
    └── me.get.ts         → GET  /api/users/me

Điều mình thích nhất là Nitro tự handle việc serialize JSON, parse body, và error handling. Viết API cực clean:

// server/api/posts/index.post.ts
import { db } from '~/server/db'
import { posts } from '~/server/db/schema'

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  
  if (!body.title || !body.content) {
    throw createError({
      statusCode: 400,
      message: 'Title and content are required'
    })
  }

  const [newPost] = await db.insert(posts)
    .values({ title: body.title, content: body.content })
    .returning()

  return newPost
})

Nitro cũng support nhiều deployment target: Node.js, Cloudflare Workers, Vercel Edge, Bun — cùng một codebase, chỉ đổi config là deploy được.

Drizzle ORM: Type-safe database mà không bị over-engineered

Mình đã dùng Prisma trước đó và thấy nó quá nặng cho project vừa. Drizzle thì khác — nó gần với SQL thật hơn, bundle size nhỏ hơn, và performance tốt hơn trong các query phức tạp.

Ví dụ query có filter và pagination:

// server/api/posts/index.get.ts
import { db } from '~/server/db'
import { posts } from '~/server/db/schema'
import { desc, like, sql } from 'drizzle-orm'

export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const page = Number(query.page) || 1
  const limit = 10
  const offset = (page - 1) * limit
  const search = query.search as string | undefined

  const conditions = search
    ? like(posts.title, `%${search}%`)
    : undefined

  const [items, [{ count }]] = await Promise.all([
    db.select()
      .from(posts)
      .where(conditions)
      .orderBy(desc(posts.createdAt))
      .limit(limit)
      .offset(offset),
    db.select({ count: sql<number>`count(*)` })
      .from(posts)
      .where(conditions)
  ])

  return {
    items,
    total: count,
    page,
    totalPages: Math.ceil(count / limit)
  }
})

TypeScript inference hoạt động rất tốt — IDE biết chính xác kiểu dữ liệu trả về từ query.

Kết nối Frontend với Backend trong cùng project

Đây là phần mình thích nhất của Nuxt 3. Trong component Vue, gọi API internal không khác gì gọi function:

<script setup lang="ts">
// useFetch tự biết URL base, tự handle SSR hydration
const { data: posts, pending, refresh } = await useFetch('/api/posts', {
  query: { page: 1 }
})

async function createPost(title: string, content: string) {
  await $fetch('/api/posts', {
    method: 'POST',
    body: { title, content }
  })
  await refresh() // reload danh sách
}
</script>

<template>
  <div>
    <div v-if="pending">Loading...</div>
    <ul v-else>
      <li v-for="post in posts?.items" :key="post.id">
        {{ post.title }}
      </li>
    </ul>
  </div>
</template>

useFetch$fetch trong Nuxt 3 tự động biết đang chạy ở server hay client, tự handle deduplication request trong SSR. Không cần cấu hình gì thêm.

Nâng cao: Middleware và Authentication

Bảo vệ API routes bằng server middleware:

// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
  // Chỉ áp dụng cho /api/admin/*
  if (!event.path.startsWith('/api/admin')) return

  const token = getHeader(event, 'authorization')?.replace('Bearer ', '')
  
  if (!token) {
    throw createError({ statusCode: 401, message: 'Unauthorized' })
  }

  // Verify token và attach user vào event context
  const user = await verifyToken(token)
  event.context.user = user
})

Dùng useStorage của Nitro cho server-side caching — không cần cài thêm Redis cho traffic vừa:

// server/api/stats.get.ts
export default defineCachedEventHandler(async () => {
  // Kết quả được cache 60 giây
  const stats = await db.select({
    count: sql<number>`count(*)`
  }).from(posts)
  
  return stats[0]
}, { maxAge: 60 })

Drizzle Migrations: Đừng bỏ qua bước này

Khi project lớn lên, cần migration thay vì drizzle-kit push. Tạo file drizzle.config.ts:

import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  schema: './server/db/schema.ts',
  out: './server/db/migrations',
  dialect: 'sqlite',
  dbCredentials: {
    url: './sqlite.db'
  }
})

Workflow migration:

# Tạo migration file sau khi thay đổi schema
npx drizzle-kit generate

# Apply migration
npx drizzle-kit migrate

# Xem trạng thái migration
npx drizzle-kit studio

Mình từng refactor codebase 50K lines và bài học lớn nhất là phải có test coverage tốt trước khi bắt đầu. Với database migrations cũng vậy — luôn test migration trên staging trước khi apply production, và giữ migration files trong git để team có thể sync dễ dàng.

Tips thực tế sau 6 tháng production

1. Tách db client thành singleton — Đừng tạo connection mới trong mỗi request. File server/db/index.ts chỉ tạo một lần nhờ module caching của Node.js.

2. Dùng server/plugins/ cho startup logic:

// server/plugins/migrations.ts
export default defineNitroPlugin(async () => {
  // Tự động chạy migration khi server start
  const { migrate } = await import('drizzle-orm/better-sqlite3/migrator')
  migrate(db, { migrationsFolder: './server/db/migrations' })
  console.log('Database migrations applied')
})

3. Validate input với Zod — Nitro không có built-in validation, kết hợp với Zod là lựa chọn tốt nhất:

npm install zod
import { z } from 'zod'

const createPostSchema = z.object({
  title: z.string().min(3).max(200),
  content: z.string().min(10)
})

export default defineEventHandler(async (event) => {
  const raw = await readBody(event)
  const body = createPostSchema.parse(raw) // throws ZodError nếu invalid
  // ...
})

4. SQLite cho small-medium project, PostgreSQL khi scale — Stack này chạy ngon với SQLite đến vài chục nghìn users. Khi cần scale, đổi sang PostgreSQL chỉ cần thay driver và update drizzle config, logic business không đổi.

5. Đừng bỏ qua .envruntimeConfig:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    databaseUrl: process.env.DATABASE_URL, // server-only
    public: {
      apiBase: '/api' // exposed to client
    }
  }
})

Khi nào nên dùng stack này?

Stack Nuxt 3 + Nitro + Drizzle phù hợp khi:

  • Team nhỏ, muốn fullstack trong một repo
  • Project vừa — SaaS, internal tools, blog có tương tác
  • Cần SSR/SSG cho SEO nhưng cũng cần API động
  • Không muốn maintain microservices khi chưa cần thiết

Không nên dùng khi backend cực kỳ phức tạp (nhiều background jobs, WebSocket phức tạp, cần horizontal scaling riêng backend) — lúc đó tách ra là hợp lý hơn.

Nhưng với 80% project thực tế mình gặp, stack này là đủ và còn dư thừa. Deploy lên một VPS $10/tháng với pm2 hoặc push thẳng lên Vercel là xong.

Share: