Fullstack interviews always reach the database: "which one and why", "how do you handle migrations", "this query is slow", "how does your app connect from a serverless function". The answers are mostly standard backend knowledge, applied through the tools Nuxt teams actually use in 2026: Drizzle first, Prisma second, Postgres by default, SQLite at the edge.
node:sqlite) and for tests. MySQL/PlanetScale where the organisation already runs it. Key-value stores (Redis, Cloudflare KV, Vercel KV) for caches, sessions and rate limits, not as the primary store. Object storage (S3, R2) for files. Document databases when the data is genuinely document-shaped; most product data is relational.drizzle-kit generate/migrate/push/studio, relational queries (db.query.orders.findMany({ with: { items: true } }), v2 relations via defineRelations), tiny runtime, edge-friendly drivers. Prisma has a schema DSL and a generated client with excellent DX and a larger runtime; recent versions dropped the Rust engine for a TypeScript client and support edge drivers. Kysely is a type-safe query builder without an ORM layer. The trade-off: Drizzle stays close to SQL and to the wire; Prisma abstracts more.push to production.server/utils/db.ts (one of the few legitimate module singletons). In serverless each instance opens its own connections and can exhaust Postgres, so use a pooler (PgBouncer, Neon's serverless driver over HTTP/WebSocket, Supabase's pooler, Cloudflare Hyperdrive) and keep pool sizes small. Edge runtimes need drivers that speak HTTP or WebSocket, not raw TCP.db.transaction(async (tx) => ...) for multi-statement invariants (create order + decrement stock); know isolation levels (read committed default in Postgres; serializable for money) and that long transactions block; retries on serialization failures.with, or IN (...) batches; measure with query logging in development.WHERE deleted_at IS NULL), GIN for JSONB and full-text; every foreign key gets an index; EXPLAIN (ANALYZE, BUFFERS) tells the truth. Indexes cost writes and space, so they follow real queries.WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT n) is stable and fast and is what APIs and infinite feeds should expose as an opaque cursor.version column and If-Match/412; UUIDv7 or ULIDs for time-ordered ids that do not leak counts (v7 is index-friendly, unlike v4); soft deletes with deleted_at when history matters; created_at/updated_at everywhere.server/utils take the db as a parameter (testability), handlers never write SQL, and rows are mapped to DTOs so a password_hash column can never reach a response.useStorage(base) over unstorage drivers configured in nitro.storage (fs in dev, redis, cloudflare-kv-binding, cloudflare-r2-binding, vercel-kv, s3, memory); right for cache entries, sessions, small blobs; not a database. NuxtHub provides Cloudflare-native database, KV and blob helpers and moved to self-hosting and multi-vendor in 2026 after its Admin sunset.Schema, connection and a service with Drizzle on Postgres:
import { index, integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
role: text('role', { enum: ['admin', 'customer'] }).notNull().default('customer'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const orders = pgTable('orders', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id),
totalMinor: integer('total_minor').notNull(),
version: integer('version').notNull().default(1),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, t => [index('orders_user_created_idx').on(t.userId, t.createdAt.desc(), t.id)]) // keyset pagination per user
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import * as schema from '~~/server/db/schema'
// Process-level pool: one of the few legitimate module singletons on the server.
const pool = new Pool({ connectionString: useRuntimeConfig().databaseUrl, max: 5 }) // small: a pooler in front handles fan-out
export const db = drizzle(pool, { schema })
export type Db = typeof db
export const useDb = () => db
import { and, desc, eq, lt, or, sql } from 'drizzle-orm'
import { orders } from '~~/server/db/schema'
export async function listOrdersForUser(db: Db, userId: string, cursor?: { createdAt: Date, id: string }, limit = 20) {
const rows = await db.select().from(orders)
.where(and(eq(orders.userId, userId), cursor
? or(lt(orders.createdAt, cursor.createdAt), and(eq(orders.createdAt, cursor.createdAt), lt(orders.id, cursor.id)))
: undefined))
.orderBy(desc(orders.createdAt), desc(orders.id))
.limit(limit + 1) // one extra row tells us whether there is a next page
const items = rows.slice(0, limit)
const last = items.at(-1)
return { items, nextCursor: rows.length > limit && last ? encodeCursor({ createdAt: last.createdAt, id: last.id }) : null }
}
export async function updateOrderNote(db: Db, id: string, userId: string, note: string, expectedVersion: number) {
const [row] = await db.update(orders)
.set({ note, version: sql`${orders.version} + 1` })
.where(and(eq(orders.id, id), eq(orders.userId, userId), eq(orders.version, expectedVersion))) // optimistic concurrency
.returning()
if (!row) throw createError({ status: 412, statusText: 'Order was modified' })
return row
}
export async function placeOrder(db: Db, input: { userId: string, items: { productId: string, qty: number }[] }) {
return db.transaction(async (tx) => {
const products = await tx.query.products.findMany({ where: (p, { inArray }) => inArray(p.id, input.items.map(i => i.productId)) }) // one query, not N
// ... compute total, decrement stock with a guarded update, insert order + items; all or nothing
})
}
Migrations in the pipeline, and Nitro storage for what is not relational:
pnpm drizzle-kit generate # SQL migration from schema diff, committed with the PR
pnpm drizzle-kit migrate # in CD, before the new version takes traffic
export default defineNuxtConfig({
nitro: {
storage: {
cache: { driver: 'redis', url: process.env.REDIS_URL }, // shared across instances
uploads: { driver: 's3', bucket: 'app-uploads', region: 'eu-central-1' },
},
devStorage: { cache: { driver: 'fs', base: '.data/cache' }, uploads: { driver: 'fs', base: '.data/uploads' } },
},
})
A Lambda or Vercel function that creates a new pool per request opens hundreds of Postgres connections under load and gets too many clients. Reuse the pool at module scope (it survives warm invocations), keep max small, and put PgBouncer, Neon's serverless driver or Hyperdrive between the functions and the database.
drizzle-kit push (or prisma db push) diffs the live database and applies changes directly, with no reviewed migration and no rollback path. Generate migrations, commit them, run them in CD; push is for prototypes.
The current Drizzle major (v1) and its relational-queries API, Prisma's current client architecture, and Cloudflare Hyperdrive's status for Postgres from Workers.
docs ↗EXPLAIN ANALYZE with an offset query at page 500.placeOrder as a transaction with a guarded stock decrement, then run two concurrent orders for the last item and confirm exactly one succeeds."Postgres by default, SQLite when the deployment is small or at the edge, a key-value store for caches and sessions, object storage for files. Drizzle as the ORM because it stays close to SQL, has schema-as-code with generated migrations, a tiny runtime and drivers for serverless and edge; Prisma is the alternative when a team prefers its DSL. The pool is a module-level singleton in server/utils/db.ts with a small size, and in serverless a pooler like PgBouncer, Neon's driver or Hyperdrive sits in front. Migrations are generated, reviewed and applied in CD before the new version serves traffic, written expand-then-contract. Queries live in services that take db as a parameter and return DTOs, so handlers stay thin and tests run against an in-memory database. And I watch the two classic problems: N+1 loops, fixed with joins or relational queries, and missing indexes, found with EXPLAIN ANALYZE."
"Cursor, or keyset, for anything that scrolls or feeds: the query says 'rows after this (created_at, id)' with a matching composite index, so it is stable when rows are inserted and stays fast at depth, and the cursor is an opaque encoded tuple in the API. Offset pagination is acceptable for admin tables where jumping to page seven matters and data changes slowly, but it drifts under inserts and the database still scans the skipped rows. Fetching limit plus one rows is the cheap way to know whether a next page exists without a count."
Nothing yet. Notes filed here show up on this page and the section overview.
Server routes and h3
Everything a Nitro handler can do — routing by file, params, query and body with validation, headers and status, cookies, event.context set in server middleware, plugins and hooks, lazy handlers, streaming responses, in-process $fetch, typed routes — and what changes with h3 v2 and nuxt/server.
Auth and sessions in Nuxt
Implementing authentication end to end with nuxt-auth-utils — sealed sessions, password and OAuth login, passkeys, session hooks — plus authorisation as data with role and object checks, SSR cookie forwarding, multi-tenant context, logout and invalidation, and the alternatives (Better Auth, Auth.js, hosted providers).