Building a Full-Stack App with Nuxt 3: Server Routes, Nitro Engine, and Drizzle ORM

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

After 6 months running Nuxt 3 in production for a small SaaS, I can say it straight: this is the stack I wish I’d known about sooner. No separate backend, no complex CORS configuration, no two repos to maintain. One project, one deploy, done.

In this post, I’ll get straight to the point — a quick 5-minute setup, then explain why it works.

Quick Start: Up and Running in 5 Minutes

Create a new project and install 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

Create a database schema file at 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())
})

Create a database client at 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 })

Create your first API route at 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()
})

Run the migration and start the dev server:

npx drizzle-kit push
npm run dev

Visit http://localhost:3000/api/posts — you’ve already got an API running. No Express, no separate Fastify needed.

Nitro Engine: How Is It Different from Express?

Nitro is the server runtime that Nuxt 3 uses under the hood. The biggest difference from traditional Express or Fastify is file-based routing — similar to Next.js, but on the server side.

The server/api/ directory structure automatically maps to 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

What I love most is that Nitro automatically handles JSON serialization, body parsing, and error handling. Writing APIs is incredibly 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 also supports multiple deployment targets: Node.js, Cloudflare Workers, Vercel Edge, Bun — same codebase, just change the config to deploy anywhere.

Drizzle ORM: Type-Safe Database Without the Over-Engineering

I used Prisma before and found it too heavy for mid-sized projects. Drizzle is different — it’s closer to raw SQL, has a smaller bundle size, and performs better on complex queries.

Example query with filtering and 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 works really well here — your IDE knows exactly what types the query will return.

Connecting Frontend to Backend in the Same Project

This is my favorite part of Nuxt 3. Inside a Vue component, calling an internal API feels just like calling a function:

<script setup lang="ts">
// useFetch knows the base URL automatically and handles 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 the list
}
</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 and $fetch in Nuxt 3 automatically know whether they’re running on the server or client, and handle request deduplication in SSR. No extra configuration needed.

Advanced: Middleware and Authentication

Protect API routes with server middleware:

// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
  // Only applies to /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 and attach user to event context
  const user = await verifyToken(token)
  event.context.user = user
})

Use Nitro’s useStorage for server-side caching — no need to add Redis for moderate traffic:

// server/api/stats.get.ts
export default defineCachedEventHandler(async () => {
  // Results are cached for 60 seconds
  const stats = await db.select({
    count: sql<number>`count(*)`
  }).from(posts)
  
  return stats[0]
}, { maxAge: 60 })

Drizzle Migrations: Don’t Skip This Step

As your project grows, you’ll need migrations instead of drizzle-kit push. Create a drizzle.config.ts file:

import { defineConfig } from 'drizzle-kit'

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

Migration workflow:

# Generate a migration file after changing the schema
npx drizzle-kit generate

# Apply migration
npx drizzle-kit migrate

# View migration status
npx drizzle-kit studio

I once refactored a 50K-line codebase and the biggest lesson was having solid test coverage before starting. The same applies to database migrations — always test migrations on staging before applying to production, and keep migration files in git so the team can sync easily.

Practical Tips After 6 Months in Production

1. Make the db client a singleton — Don’t create a new connection on every request. The server/db/index.ts file is only instantiated once thanks to Node.js module caching.

2. Use server/plugins/ for startup logic:

// server/plugins/migrations.ts
export default defineNitroPlugin(async () => {
  // Automatically run migrations when the server starts
  const { migrate } = await import('drizzle-orm/better-sqlite3/migrator')
  migrate(db, { migrationsFolder: './server/db/migrations' })
  console.log('Database migrations applied')
})

3. Validate input with Zod — Nitro doesn’t have built-in validation, and pairing it with Zod is the best approach:

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 if invalid
  // ...
})

4. SQLite for small-to-medium projects, PostgreSQL when scaling — This stack runs great with SQLite up to tens of thousands of users. When you need to scale, switching to PostgreSQL only requires changing the driver and updating the drizzle config — your business logic stays the same.

5. Don’t skip .env and runtimeConfig:

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

When Should You Use This Stack?

The Nuxt 3 + Nitro + Drizzle stack is a great fit when:

  • You have a small team and want full-stack in a single repo
  • Your project is mid-sized — SaaS, internal tools, interactive blogs
  • You need SSR/SSG for SEO but also need a dynamic API
  • You don’t want to maintain microservices before you actually need them

Avoid it when the backend is extremely complex — lots of background jobs, complex WebSockets, or the backend needs independent horizontal scaling. In those cases, splitting them out makes more sense.

But for 80% of real-world projects I’ve encountered, this stack is more than enough. Deploy to a $10/month VPS with pm2 or push straight to Vercel and you’re done.

Share: