# Nextjs Fullstack Patterns

> Full-stack Next.js backend-layer conventions: Drizzle schema and queries, route handlers, server actions, the service layer, Auth.js, and Redis/BullMQ. Apply when writing or reviewing the data/API layer of a full-stack Next.js app.

- Skill: `theyoungastronauts/nextjs-fullstack-patterns` (Agent Skill)
- Install (CLI): `npx skillmds@latest add theyoungastronauts/nextjs-fullstack-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/theyoungastronauts/nextjs-fullstack-patterns/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: theyoungastronauts (https://skillmd.com/u/theyoungastronauts)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/theyoungastronauts/nextjs-fullstack-patterns

---


# 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.ts` or `apiFetch` wrapper; 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:
  ```ts
  export const timestamps = {
    createdAt: timestamp("created_at").defaultNow().notNull(),
    updatedAt: timestamp("updated_at").defaultNow().notNull().$onUpdate(() => new Date()),
  };
  ```
- Use `pgTable` from `drizzle-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()` with `with` for 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 `db` instance in `lib/db/index.ts` using `drizzle(pool)` with `node-postgres`
- Use connection pooling via `Pool` — not single `Client`
- Export the typed `db` for 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.ts` re-exports `handlers`
- 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 `auth` as middleware in `middleware.ts` for 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.ts` if 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:
  ```ts
  { error: "Validation failed", details: z.flattenError(zodError).fieldErrors }
  ```

### Response Shape
- Collections use the canonical paginated envelope: `{ page, count, num_pages, results: T[] }` with `page`/`page_size` query 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:
  ```ts
  type ActionResult<T = void> = { success: true; data: T } | { success: false; error: string };
  ```
- Use `revalidatePath()` or `revalidateTag()` 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/headers` or Next.js request APIs — they receive data as arguments

### Testing
- Services are the primary unit-test target — test business logic without HTTP
- Mock the `db` and `redis` clients, not the service functions themselves
- Test route handlers and server actions as integration tests

## Redis

- Single client in `lib/redis.ts` using `ioredis`
- 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.parse` for 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/test` or direct fetch
- Test server actions by calling them directly in tests
- Use `drizzle-kit` test 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 run `npm` directly
- Use `/nextjs-fullstack-bootstrap` when setting up a new project from scratch
- Services: app (Next.js), db (Postgres), redis
- Hot reload via `WATCHPACK_POLLING=true` inside Docker
- `node_modules` lives inside the container (anonymous volume)
- Database URL via environment variable: `DATABASE_URL=postgres://...`

