Skill: Next.js Full-Stack Patterns
Purpose
Guide Claude Code when implementing full-stack Next.js features. This covers the backend layer — Drizzle ORM, Postgres, Redis, BullMQ, Auth.js, Route Handlers, and Server Actions. Pair with nextjs-patterns.md (loaded via the frontend profile) for component, styling, and naming conventions.
When using fullstack mode, ignore these sections of nextjs-patterns.md
- API Client — no
lib/api.tsorapiFetchwrapper; data access goes through the service layer directly - Frontend-Centric Mode — no JWT/localStorage auth; use Auth.js instead
- The SSR-Centric Mode section still applies
Project Structure
src/
├── app/
│ ├── api/ # Route Handlers (REST endpoints)
│ │ ├── auth/[...nextauth]/route.ts
│ │ └── v1/
│ │ └── {resource}/route.ts
│ ├── (auth)/ # Auth pages (login, register, etc.)
│ ├── (dashboard)/ # Authenticated app pages
│ ├── layout.tsx
│ └── page.tsx
├── components/ # React components (managed by frontend profile)
├── lib/
│ ├── auth.ts # Auth.js configuration
│ ├── db/
│ │ ├── index.ts # Drizzle client (singleton)
│ │ ├── schema/ # Table definitions, one file per domain
│ │ │ ├── users.ts
│ │ │ ├── index.ts # Re-exports all tables
│ │ │ └── ...
│ │ └── migrations/ # Generated by drizzle-kit
│ ├── redis.ts # Redis client (singleton)
│ └── queue/
│ ├── client.ts # Shared BullMQ connection
│ ├── queues.ts # Queue definitions
│ └── workers.ts # Worker definitions
├── server/
│ ├── actions/ # Server Actions, one file per domain
│ │ ├── auth.ts
│ │ └── {resource}.ts
│ └── services/ # Business logic, one file per domain
│ ├── auth.ts
│ └── {resource}.ts
└── types/
└── index.ts # Shared types
Drizzle ORM
Schema
- Define tables in
lib/db/schema/, one file per domain - Every table gets
id(serial primary key),uuid(for public-facing IDs),createdAt,updatedAt - Use a shared helper for the common columns:
export const timestamps = { createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull().$onUpdate(() => new Date()), }; - Use
pgTablefromdrizzle-orm/pg-core - Define relations in the same file as the table using
relations() - Export all tables from
lib/db/schema/index.ts
Queries
- Use the Drizzle query builder — not raw SQL
- Use
db.select()/db.insert()/db.update()/db.delete()for simple operations - Use
db.query.tableName.findMany()/findFirst()withwithfor relational queries - Always scope queries: filter by tenant/user, never return unscoped data
- Use
.$dynamic()for conditional query building
Migrations
- Generate with
drizzle-kit generate - Apply with
drizzle-kit migrate - Review generated SQL before committing — never blindly apply
- Never edit a migration that's been pushed to main
- Run migrations via Makefile:
make db-generate,make db-migrate
Client
- Single
dbinstance inlib/db/index.tsusingdrizzle(pool)withnode-postgres - Use connection pooling via
Pool— not singleClient - Export the typed
dbfor use in services
Auth.js (v5)
Setup
- Configuration in
lib/auth.ts— export{ handlers, auth, signIn, signOut } - Route handler at
app/api/auth/[...nextauth]/route.tsre-exportshandlers - Use the Drizzle adapter (
@auth/drizzle-adapter) for session/account storage - Default to database sessions (not JWT) for fullstack apps
Protecting Routes
- Server Components: call
auth()to get the session, redirect if null - Route Handlers: call
auth()at the top, return 401 if null - Server Actions: call
auth()at the top, throw if null - Middleware: use
authas middleware inmiddleware.tsfor blanket route protection - Define public routes explicitly in middleware matcher config
Patterns
- Store minimal user data in the session — fetch full profile from DB when needed
- Extend the session type in
types/next-auth.d.tsif adding fields - Use
auth()— never parse cookies or tokens manually
Route Handlers (API Routes)
Conventions
- Place under
app/api/v1/{resource}/route.ts - Export named functions matching HTTP methods:
GET,POST,PUT,PATCH,DELETE - Parse request body with
request.json()and validate with Zod - Return
NextResponse.json()with appropriate status codes - Route handlers call services — no business logic in the handler itself
Validation
- Use Zod schemas for all request validation
- Define schemas alongside the route or in a shared
lib/validators/directory - Return 400 with structured error response on validation failure:
{ error: "Validation failed", details: z.flattenError(zodError).fieldErrors }
Response Shape
- Collections use the canonical paginated envelope:
{ page, count, num_pages, results: T[] }withpage/page_sizequery params (matches the cross-stack API contract) - Single resources: return the object directly (no envelope)
- Errors:
{ error: string, details?: Record<string, string[]> }
Server Actions
Conventions
- Files in
server/actions/, one per domain, with"use server"at the top - Name actions as verbs:
createItem,updateItem,deleteItem - Always validate input with Zod before processing
- Always check auth via
auth()before any data mutation - Actions call services — no business logic in the action itself
Return Pattern
- Return a result object, not void:
type ActionResult<T = void> = { success: true; data: T } | { success: false; error: string }; - Use
revalidatePath()orrevalidateTag()after mutations to bust caches - Never redirect inside a try/catch — call
redirect()outside it (it throws internally)
Service Layer
Conventions
- Each domain has a service file in
server/services/ - Services are plain functions (not classes) that take explicit dependencies
- Services contain all business logic — route handlers and server actions are thin wrappers
- Services call Drizzle for data access, queue jobs, send emails, etc.
- Services never import from
next/headersor Next.js request APIs — they receive data as arguments
Testing
- Services are the primary unit-test target — test business logic without HTTP
- Mock the
dbandredisclients, not the service functions themselves - Test route handlers and server actions as integration tests
Redis
- Single client in
lib/redis.tsusingioredis - Use for: caching, rate limiting, session storage (if needed beyond Auth.js), BullMQ connection
- Key naming convention:
{app}:{domain}:{id}(e.g.,myapp:user:123:profile) - Always set TTL on cache keys — no indefinite caching
- Use
JSON.stringify/JSON.parsefor complex values
BullMQ
Queues
- Define queues in
lib/queue/queues.ts— one queue per job domain - Use the shared Redis connection from
lib/queue/client.ts - Name queues in kebab-case:
email-notifications,data-processing
Jobs
- Jobs call services — no business logic in the job processor itself
- Keep job payloads small and serializable — pass IDs, not full objects
- Set sensible defaults:
attempts: 3,backoff: { type: "exponential", delay: 1000 }
Workers
- Define workers in
lib/queue/workers.ts - Workers run in the same process during development
- For production, consider a separate worker entry point
Testing
- Use Vitest as the test runner
- Test services independently with unit tests (mock db/redis)
- Test route handlers with integration tests using
next/testor direct fetch - Test server actions by calling them directly in tests
- Use
drizzle-kittest utilities or a test database for integration tests - Factory functions for test data (not fixtures) — keep in
tests/factories/
Development Environment
- All commands run via
make(Docker Compose under the hood) — never runnpmdirectly - Use
/nextjs-fullstack-bootstrapwhen setting up a new project from scratch - Services: app (Next.js), db (Postgres), redis
- Hot reload via
WATCHPACK_POLLING=trueinside Docker node_moduleslives inside the container (anonymous volume)- Database URL via environment variable:
DATABASE_URL=postgres://...