Fullstack with Nitro

Data layer and databases

Choosing Postgres, SQLite or a KV, Drizzle versus Prisma versus Kysely, migrations in CI, connection pooling in serverless and edge, transactions and isolation, N+1 and indexes, keyset pagination, optimistic concurrency, DTOs, and Nitro's storage layer for KV and blobs.

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.

Know

  • Choosing a store: Postgres is the default relational database (Neon, Supabase, RDS, self-hosted): constraints, joins, JSONB, full-text search, mature tooling. SQLite shines for small or edge deployments (Cloudflare D1, Turso/libsql, 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.
  • ORMs and builders: Drizzle (v1) is SQL-like TypeScript with schema-as-code, 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.
  • Migrations: generated from the schema, committed, reviewed like code, applied in CI/CD before the new version serves traffic, and written backward compatible (expand → migrate data → contract) so old and new instances coexist during a rolling deploy; never push to production.
  • Connections: a pool is process-level and belongs at module scope in 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.
  • Transactions: 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.
  • N+1 and joins: a loop of queries per row is the classic slow endpoint; use joins, relational queries with with, or IN (...) batches; measure with query logging in development.
  • Indexes: B-tree for equality and ranges, composite indexes ordered by selectivity and query shape, covering indexes to avoid table lookups, partial indexes for common filters (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.
  • Pagination: offset pagination is simple and jumps to pages but drifts and slows at depth; keyset (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.
  • Concurrency and identity: optimistic concurrency with a 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.
  • Data access layer: repositories or services in 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.
  • Nitro storage: 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.
  • Operations: backups and point-in-time recovery, read replicas for reporting, connection and slow-query metrics, and a plan for schema changes on large tables (concurrent index creation, batched backfills).

How it works

Schema, connection and a service with Drizzle on Postgres:

server/db/schema.ts
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
server/utils/db.ts
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
server/utils/orders.ts
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
nuxt.config.ts
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' } },
  },
})
Gotcha· One connection per serverless invocation

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.

Gotcha· `db push` in production

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.

Verify before the interview:

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 ↗

Exercise

Exercise
  • Model users, products, orders and order items in Drizzle, generate the migration, apply it to a local Postgres and an in-memory SQLite, and note the dialect differences.
  • Implement keyset pagination and compare EXPLAIN ANALYZE with an offset query at page 500.
  • Write placeOrder as a transaction with a guarded stock decrement, then run two concurrent orders for the last item and confirm exactly one succeeds.

Be able to say

Be able to say· How do you set up the data layer for a Nuxt application?

"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."

Be able to say· Offset or cursor pagination?

"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."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.