# Nextjs Fullstack Bootstrap

> Add full-stack backend infrastructure to a Next.js app (Drizzle, Postgres, Redis, BullMQ, Auth.js).

- Skill: `theyoungastronauts/nextjs-fullstack-bootstrap` (Agent Skill)
- Install (CLI): `npx skillmds@latest add theyoungastronauts/nextjs-fullstack-bootstrap`
- Raw SKILL.md: https://api.skillmd.com/api/skills/theyoungastronauts/nextjs-fullstack-bootstrap/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-bootstrap

---


# Next.js Full-Stack Bootstrap

On-demand command for adding backend infrastructure to a Next.js project — Drizzle ORM, Postgres, Redis, BullMQ, and Auth.js. Supports three blueprint tiers for right-sized infrastructure.

> **This bootstrap adds backend pieces to an existing Next.js project.** Run the appropriate frontend bootstrap first (`/nextjs-bootstrap`, etc.) to create the base project, then run this to add the backend layer.

> **MANDATORY: This project runs entirely in Docker.**
> All Docker service additions (db, redis, worker) MUST be added to the existing docker-compose.yml.
> The Makefile is the sole interface — never run `npm` or `npx` directly on the host.

## Before You Start

Ask the user for these values (provide defaults where shown):

| Placeholder | Description | Example |
|-------------|-------------|---------|
| `{blueprint}` | Blueprint tier: `minimal`, `standard`, or `full` | `standard` |
| `{app-name}` | Existing project directory name | `my-app` |
| `{compose-project}` | Docker Compose project name (kebab-case) | `my-app` |
| `{db_name}` | Postgres database name (snake_case) | `my_app` |
| `{host_port}` | Host port for Next.js (should match existing) | `3000` |

### Blueprint Tiers

| Tier | Name | Includes | Use when |
|------|------|----------|----------|
| 1 | **minimal** | Drizzle + Postgres + Docker services. No Redis, no auth, no queues, no Sentry. | Simple full-stack apps, prototypes, no user accounts |
| 2 | **standard** | Minimal + Redis cache + Auth.js + Sentry. No BullMQ, S3, email. | Most production apps with auth |
| 3 | **full** | Standard + BullMQ workers + S3/R2 + email. | Background tasks, file uploads, notifications |

> **Only generate files and sections marked for your blueprint tier.** Sections are tagged `[ALL]`, `[STANDARD+]`, or `[FULL]`. Generate `[ALL]` always, `[STANDARD+]` for standard and full, `[FULL]` only for full.

## Bootstrapping Steps

1. Verify the base Next.js project exists with `src/app/` structure
2. Install dependencies (add to `package.json`)
3. Create backend files from templates below (respecting blueprint tier tags)
4. Update `docker-compose.yml` with database and Redis services
5. Update `Makefile` with database and queue commands
6. Update `.env.local` with new environment variables
7. `make build && make up`
8. `make db-generate && make db-migrate`
9. Verify at `http://localhost:{host_port}`

---

## New Files to Create

### Directory Structure (additions to existing project)

**`[MINIMAL]`:**

```
src/
├── lib/
│   └── db/
│       ├── index.ts            # Drizzle client
│       ├── schema/
│       │   ├── index.ts        # Re-exports all tables
│       │   └── posts.ts        # Example table
│       └── migrations/         # Generated by drizzle-kit
├── server/
│   ├── actions/
│   │   └── posts.ts            # Example server actions
│   └── services/
│       └── posts.ts            # Example service
├── app/
│   └── api/
│       └── v1/
│           └── posts/
│               └── route.ts    # Example route handler
drizzle.config.ts
```

**`[STANDARD]`** — adds auth, redis, middleware:

```
src/
├── lib/
│   ├── auth.ts                 # Auth.js configuration
│   ├── redis.ts                # Redis client
│   └── db/
│       ├── index.ts
│       ├── schema/
│       │   ├── index.ts
│       │   ├── users.ts        # Auth.js user/account/session tables
│       │   └── posts.ts
│       └── migrations/
├── server/
│   ├── actions/
│   │   └── posts.ts
│   └── services/
│       └── posts.ts
├── app/
│   ├── api/
│   │   ├── auth/
│   │   │   └── [...nextauth]/
│   │   │       └── route.ts    # Auth.js route handler
│   │   └── v1/
│   │       └── posts/
│   │           └── route.ts
│   └── (auth)/
│       ├── login/
│       │   └── page.tsx
│       └── register/
│           └── page.tsx
├── types/
│   └── next-auth.d.ts          # Session type extensions
middleware.ts
drizzle.config.ts
```

**`[FULL]`** — adds BullMQ, storage service:

```
src/
├── lib/
│   ├── auth.ts
│   ├── redis.ts
│   ├── db/
│   │   ├── index.ts
│   │   ├── schema/
│   │   │   ├── index.ts
│   │   │   ├── users.ts
│   │   │   └── posts.ts
│   │   └── migrations/
│   └── queue/
│       ├── client.ts           # Shared BullMQ connection
│       ├── queues.ts           # Queue definitions
│       └── workers.ts          # Worker processors
├── server/
│   ├── actions/
│   │   └── posts.ts
│   └── services/
│       ├── posts.ts
│       ├── storage.ts          # S3/R2 file uploads
│       └── email.ts            # Email via Resend
├── app/
│   ├── api/
│   │   ├── auth/
│   │   │   └── [...nextauth]/
│   │   │       └── route.ts
│   │   └── v1/
│   │       └── posts/
│   │           └── route.ts
│   └── (auth)/
│       ├── login/
│       │   └── page.tsx
│       └── register/
│           └── page.tsx
├── types/
│   └── next-auth.d.ts
middleware.ts
drizzle.config.ts
```

---

## File Templates

### drizzle.config.ts `[ALL]`

```ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/lib/db/schema/index.ts",
  out: "./src/lib/db/migrations",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});
```

### src/lib/db/index.ts `[ALL]`

```ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

export const db = drizzle(pool, { schema });
```

### src/lib/db/schema/index.ts `[ALL]`

**`[MINIMAL]`:**

```ts
export * from "./posts";
```

**`[STANDARD+]`:**

```ts
export * from "./users";
export * from "./posts";
```

### src/lib/db/schema/posts.ts `[ALL]`

```ts
import { pgTable, serial, text, timestamp, uuid, index } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./users";

export const posts = pgTable(
  "posts",
  {
    id: serial("id").primaryKey(),
    uuid: uuid("uuid").defaultRandom().unique().notNull(),
    // Owner — ties each post to the authenticated user (STANDARD+ auth tier).
    // Minimal (no-auth) blueprint: drop this column and the auth()/user scoping
    // shown in the service, actions, and route below; posts are then unowned.
    userId: text("user_id")
      .notNull()
      .references(() => users.id, { onDelete: "cascade" }),
    title: text("title").notNull(),
    content: text("content"),
    createdAt: timestamp("created_at").defaultNow().notNull(),
    updatedAt: timestamp("updated_at")
      .defaultNow()
      .notNull()
      .$onUpdate(() => new Date()),
  },
  (t) => [index("posts_user_id_idx").on(t.userId)]
);

export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
```

### src/lib/db/schema/users.ts `[STANDARD+]`

```ts
import {
  pgTable,
  serial,
  text,
  timestamp,
  uuid,
  integer,
  primaryKey,
} from "drizzle-orm/pg-core";
import type { AdapterAccountType } from "next-auth/adapters";

export const users = pgTable("users", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  name: text("name"),
  email: text("email").unique().notNull(),
  emailVerified: timestamp("email_verified", { mode: "date" }),
  image: text("image"),
  hashedPassword: text("hashed_password"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at")
    .defaultNow()
    .notNull()
    .$onUpdate(() => new Date()),
});

export const accounts = pgTable(
  "accounts",
  {
    userId: text("user_id")
      .notNull()
      .references(() => users.id, { onDelete: "cascade" }),
    type: text("type").$type<AdapterAccountType>().notNull(),
    provider: text("provider").notNull(),
    providerAccountId: text("provider_account_id").notNull(),
    refresh_token: text("refresh_token"),
    access_token: text("access_token"),
    expires_at: integer("expires_at"),
    token_type: text("token_type"),
    scope: text("scope"),
    id_token: text("id_token"),
    session_state: text("session_state"),
  },
  (account) => [
    primaryKey({ columns: [account.provider, account.providerAccountId] }),
  ]
);

export const sessions = pgTable("sessions", {
  sessionToken: text("session_token").primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  expires: timestamp("expires", { mode: "date" }).notNull(),
});

export const verificationTokens = pgTable(
  "verification_tokens",
  {
    identifier: text("identifier").notNull(),
    token: text("token").notNull(),
    expires: timestamp("expires", { mode: "date" }).notNull(),
  },
  (vt) => [primaryKey({ columns: [vt.identifier, vt.token] })]
);

export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
```

### src/lib/auth.ts `[STANDARD+]`

```ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/lib/db";
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { users } from "@/lib/db/schema";

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  // Credentials logins never create database session rows in Auth.js —
  // the JWT strategy is required for them to work, even with an adapter
  session: { strategy: "jwt" },
  pages: {
    signIn: "/login",
  },
  providers: [
    Credentials({
      name: "credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) return null;

        const user = await db.query.users.findFirst({
          where: eq(users.email, credentials.email as string),
        });

        if (!user?.hashedPassword) return null;

        const isValid = await bcrypt.compare(
          credentials.password as string,
          user.hashedPassword
        );

        if (!isValid) return null;

        return { id: user.id, name: user.name, email: user.email };
      },
    }),
    // Add OAuth providers here:
    // Google({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET! }),
  ],
  callbacks: {
    jwt({ token, user }) {
      if (user) token.id = user.id;
      return token;
    },
    session({ session, token }) {
      session.user.id = token.id as string;
      return session;
    },
  },
});
```

### src/app/api/auth/[...nextauth]/route.ts `[STANDARD+]`

```ts
import { handlers } from "@/lib/auth";

export const { GET, POST } = handlers;
```

### src/middleware.ts `[STANDARD+]`

```ts
import { auth } from "@/lib/auth";

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isAuthPage =
    req.nextUrl.pathname.startsWith("/login") ||
    req.nextUrl.pathname.startsWith("/register");
  const isApiAuth = req.nextUrl.pathname.startsWith("/api/auth");
  const isPublic =
    req.nextUrl.pathname === "/" || req.nextUrl.pathname.startsWith("/api/v1");

  // Allow auth API routes and public pages
  if (isApiAuth || isPublic) return;

  // Redirect logged-in users away from auth pages
  if (isAuthPage && isLoggedIn) {
    return Response.redirect(new URL("/dashboard", req.nextUrl));
  }

  // Redirect unauthenticated users to login
  if (!isAuthPage && !isLoggedIn) {
    return Response.redirect(new URL("/login", req.nextUrl));
  }
});

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.png$).*)"],
};
```

### src/types/next-auth.d.ts `[STANDARD+]`

```ts
import { DefaultSession } from "next-auth";

declare module "next-auth" {
  interface Session {
    user: {
      id: string;
    } & DefaultSession["user"];
  }
}
```

### src/lib/redis.ts `[STANDARD+]`

```ts
import Redis from "ioredis";

const globalForRedis = globalThis as unknown as { redis: Redis | undefined };

export const redis =
  globalForRedis.redis ??
  new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");

if (process.env.NODE_ENV !== "production") globalForRedis.redis = redis;
```

### src/lib/queue/client.ts `[FULL]`

```ts
import Redis from "ioredis";

// Shared connection for BullMQ — separate from the cache client
// BullMQ requires maxRetriesPerRequest: null
export const queueConnection = new Redis(
  process.env.REDIS_URL ?? "redis://localhost:6379",
  { maxRetriesPerRequest: null }
);
```

### src/lib/queue/queues.ts `[FULL]`

```ts
import { Queue } from "bullmq";
import { queueConnection } from "./client";

export const emailQueue = new Queue("email-notifications", {
  connection: queueConnection,
  defaultJobOptions: {
    attempts: 3,
    backoff: { type: "exponential", delay: 1000 },
    removeOnComplete: { count: 100 },
    removeOnFail: { count: 500 },
  },
});

// Add more queues as needed:
// export const dataProcessingQueue = new Queue("data-processing", { ... });
```

### src/lib/queue/workers.ts `[FULL]`

```ts
import { Worker } from "bullmq";
import { queueConnection } from "./client";

// Email notification worker
const emailWorker = new Worker(
  "email-notifications",
  async (job) => {
    const { to, subject, body } = job.data;
    // Call the email service here
    console.log(`Processing email job ${job.id}: ${subject} -> ${to}`);
  },
  { connection: queueConnection, concurrency: 5 }
);

emailWorker.on("completed", (job) => {
  console.log(`Job ${job.id} completed`);
});

emailWorker.on("failed", (job, err) => {
  console.error(`Job ${job?.id} failed:`, err.message);
});

export { emailWorker };
```

### src/server/services/posts.ts `[ALL]`

```ts
import { db } from "@/lib/db";
import { posts, type NewPost, type Post } from "@/lib/db/schema";
import { and, count, eq } from "drizzle-orm";

// Queries are scoped to the owning user (STANDARD+ auth tier). Minimal
// (no-auth) blueprint: drop the userId parameters and the userId filters.
export async function listPosts(
  userId: string,
  { page, pageSize }: { page: number; pageSize: number }
): Promise<{ results: Post[]; count: number }> {
  const offset = (page - 1) * pageSize;
  const [results, totals] = await Promise.all([
    db.query.posts.findMany({
      where: eq(posts.userId, userId),
      orderBy: (posts, { desc }) => [desc(posts.createdAt)],
      limit: pageSize,
      offset,
    }),
    db.select({ total: count() }).from(posts).where(eq(posts.userId, userId)),
  ]);
  return { results, count: totals[0].total };
}

export async function getPost(
  uuid: string,
  userId: string
): Promise<Post | undefined> {
  return db.query.posts.findFirst({
    where: and(eq(posts.uuid, uuid), eq(posts.userId, userId)),
  });
}

export async function createPost(data: NewPost): Promise<Post> {
  const [post] = await db.insert(posts).values(data).returning();
  return post;
}

export async function updatePost(
  uuid: string,
  userId: string,
  data: Partial<NewPost>
): Promise<Post | undefined> {
  const [post] = await db
    .update(posts)
    .set(data)
    .where(and(eq(posts.uuid, uuid), eq(posts.userId, userId)))
    .returning();
  return post;
}

export async function deletePost(uuid: string, userId: string): Promise<void> {
  await db.delete(posts).where(and(eq(posts.uuid, uuid), eq(posts.userId, userId)));
}
```

### src/server/actions/posts.ts `[ALL]`

```ts
"use server";

import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import * as postService from "@/server/services/posts";
import { z } from "zod";

type ActionResult<T = void> =
  | { success: true; data: T }
  | { success: false; error: string };

const createPostSchema = z.object({
  title: z.string().trim().min(1, "Title is required"),
  content: z.string().optional(),
});

// auth() gates every mutation (STANDARD+ auth tier). Minimal (no-auth)
// blueprint: drop the session checks and the userId argument.
export async function createPost(
  formData: FormData
): Promise<ActionResult<{ uuid: string }>> {
  const session = await auth();
  if (!session?.user?.id) {
    return { success: false, error: "Unauthorized" };
  }

  const parsed = createPostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content") ?? undefined,
  });
  if (!parsed.success) {
    return { success: false, error: parsed.error.issues[0].message };
  }

  const post = await postService.createPost({
    ...parsed.data,
    userId: session.user.id,
  });
  revalidatePath("/posts");
  return { success: true, data: { uuid: post.uuid } };
}

export async function deletePost(uuid: string): Promise<ActionResult> {
  const session = await auth();
  if (!session?.user?.id) {
    return { success: false, error: "Unauthorized" };
  }

  await postService.deletePost(uuid, session.user.id);
  revalidatePath("/posts");
  return { success: true, data: undefined };
}
```

### src/app/api/v1/posts/route.ts `[ALL]`

```ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import * as postService from "@/server/services/posts";
import { z } from "zod";

const createPostSchema = z.object({
  title: z.string().min(1, "Title is required"),
  content: z.string().optional(),
});

// middleware treats /api/v1/* as public, so each handler authenticates itself
// (STANDARD+ auth tier). Minimal (no-auth) blueprint: drop the auth() guards
// and the userId scoping.
export async function GET(request: NextRequest) {
  const session = await auth();
  if (!session?.user?.id) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Canonical pagination contract: page/page_size params → {page, count, num_pages, results}
  const { searchParams } = new URL(request.url);
  const page = Math.max(1, Number(searchParams.get("page")) || 1);
  const pageSize = Math.min(
    100,
    Math.max(1, Number(searchParams.get("page_size")) || 20)
  );

  const { results, count } = await postService.listPosts(session.user.id, {
    page,
    pageSize,
  });
  return NextResponse.json({
    page,
    count,
    num_pages: Math.max(1, Math.ceil(count / pageSize)),
    results,
  });
}

export async function POST(request: NextRequest) {
  const session = await auth();
  if (!session?.user?.id) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = await request.json();
  const result = createPostSchema.safeParse(body);

  if (!result.success) {
    return NextResponse.json(
      { error: "Validation failed", details: z.flattenError(result.error).fieldErrors },
      { status: 400 }
    );
  }

  const post = await postService.createPost({
    ...result.data,
    userId: session.user.id,
  });
  return NextResponse.json(post, { status: 201 });
}
```

### src/server/services/storage.ts `[FULL]`

```ts
import {
  S3Client,
  PutObjectCommand,
  DeleteObjectCommand,
  GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({
  region: "auto",
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

const bucket = process.env.R2_BUCKET_NAME!;

export async function uploadFile(
  key: string,
  body: Buffer | ReadableStream,
  contentType: string
): Promise<string> {
  await s3.send(
    new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: contentType })
  );

  if (process.env.R2_CUSTOM_DOMAIN) {
    return `https://${process.env.R2_CUSTOM_DOMAIN}/${key}`;
  }
  return key;
}

export async function deleteFile(key: string): Promise<void> {
  await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
}

export async function getSignedDownloadUrl(
  key: string,
  expiresIn = 3600
): Promise<string> {
  return getSignedUrl(
    s3,
    new GetObjectCommand({ Bucket: bucket, Key: key }),
    { expiresIn }
  );
}
```

### src/server/services/email.ts `[FULL]`

```ts
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

interface SendEmailOptions {
  to: string | string[];
  subject: string;
  html: string;
  from?: string;
}

export async function sendEmail({
  to,
  subject,
  html,
  from = process.env.DEFAULT_FROM_EMAIL ?? "noreply@example.com",
}: SendEmailOptions) {
  const { data, error } = await resend.emails.send({
    from,
    to: Array.isArray(to) ? to : [to],
    subject,
    html,
  });

  if (error) {
    throw new Error(`Failed to send email: ${error.message}`);
  }

  return data;
}
```

---

## Docker Services (add to existing docker-compose.yml)

### `[MINIMAL]` — add db service

```yaml
  db:
    image: postgres:16
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB={db_name}
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    ports:
      - "5432:5432"

volumes:
  postgres_data:
```

Also update the existing `app` service:
```yaml
  app:
    depends_on:
      - db
    environment:
      - DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
```

### `[STANDARD]` — add db + redis

```yaml
  db:
    image: postgres:16
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB={db_name}
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    ports:
      - "5432:5432"

  redis:
    image: redis:7
    volumes:
      - redis_data:/data

volumes:
  postgres_data:
  redis_data:
```

Also update the existing `app` service:
```yaml
  app:
    depends_on:
      - db
      - redis
    environment:
      - DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
      - REDIS_URL=redis://redis:6379/0
```

### `[FULL]` — add db + redis + worker

```yaml
  db:
    image: postgres:16
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB={db_name}
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    ports:
      - "5432:5432"

  redis:
    image: redis:7
    volumes:
      - redis_data:/data

  worker:
    build:
      context: .
      dockerfile: Dockerfile.dev
    command: npx tsx --watch src/lib/queue/workers.ts
    volumes:
      - .:/app
      - /app/node_modules
    depends_on:
      - redis
      - db
    env_file:
      - .env.local
    environment:
      - DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
      - REDIS_URL=redis://redis:6379/0

volumes:
  postgres_data:
  redis_data:
```

Also update the existing `app` service:
```yaml
  app:
    depends_on:
      - db
      - redis
    environment:
      - DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
      - REDIS_URL=redis://redis:6379/0
```

---

## Makefile Additions (append to existing Makefile)

### `[ALL]` — Database commands

```makefile
# ============================================================================
# Database (Drizzle)
# ============================================================================

.PHONY: db-generate db-migrate db-push db-studio db-seed dbshell dbreset

db-generate:
	$(DOCKER_COMPOSE) exec app npx drizzle-kit generate

db-migrate:
	$(DOCKER_COMPOSE) exec app npx drizzle-kit migrate

db-push:
	$(DOCKER_COMPOSE) exec app npx drizzle-kit push

db-studio:
	$(DOCKER_COMPOSE) exec app npx drizzle-kit studio

dbshell:
	$(DOCKER_COMPOSE) exec db psql -U postgres -d {db_name}

dbreset:
	@echo "WARNING: This will delete the database!"
	@read -p "Are you sure? [y/N] " confirm && [ "$$confirm" = "y" ]
	$(DOCKER_COMPOSE) exec db psql -U postgres -c "DROP DATABASE IF EXISTS {db_name};"
	$(DOCKER_COMPOSE) exec db psql -U postgres -c "CREATE DATABASE {db_name};"
	$(DOCKER_COMPOSE) exec app npx drizzle-kit migrate
```

### `[FULL]` — Queue commands

```makefile
# ============================================================================
# Queue (BullMQ)
# ============================================================================

.PHONY: worker logs-worker

worker:
	$(DOCKER_COMPOSE) exec worker npx tsx src/lib/queue/workers.ts

logs-worker:
	$(DOCKER_COMPOSE) logs -f worker
```

---

## Package Dependencies (add to existing package.json)

### `[MINIMAL]`

```json
{
  "dependencies": {
    "drizzle-orm": "^0.45",
    "pg": "^8.13",
    "zod": "^4.4"
  },
  "devDependencies": {
    "drizzle-kit": "^0.31",
    "@types/pg": "^8.11"
  }
}
```

### `[STANDARD]` (adds auth, redis, bcrypt)

```json
{
  "dependencies": {
    "drizzle-orm": "^0.45",
    "pg": "^8.13",
    "zod": "^4.4",
    "next-auth": "5.0.0-beta.31",
    "@auth/drizzle-adapter": "^1.11",
    "bcryptjs": "^3.0",
    "ioredis": "^5.4"
  },
  "devDependencies": {
    "drizzle-kit": "^0.31",
    "@types/pg": "^8.11"
  }
}
```

### `[FULL]` (adds bullmq, s3, resend)

```json
{
  "dependencies": {
    "drizzle-orm": "^0.45",
    "pg": "^8.13",
    "zod": "^4.4",
    "next-auth": "5.0.0-beta.31",
    "@auth/drizzle-adapter": "^1.11",
    "bcryptjs": "^3.0",
    "ioredis": "^5.4",
    "bullmq": "^5.30",
    "@aws-sdk/client-s3": "^3.700",
    "@aws-sdk/s3-request-presigner": "^3.700",
    "resend": "^4.1"
  },
  "devDependencies": {
    "drizzle-kit": "^0.31",
    "@types/pg": "^8.11",
    "tsx": "^4.19"
  }
}
```

---

## Environment Variables (add to existing .env.local)

### `[MINIMAL]`

```bash
# Database
DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
```

### `[STANDARD]` (adds redis, auth)

```bash
# Database
DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}

# Redis
REDIS_URL=redis://redis:6379/0

# Auth.js
AUTH_SECRET=generate-a-random-secret-here
AUTH_URL=http://localhost:{host_port}

# Error Tracking
SENTRY_DSN=
```

### `[FULL]` (adds storage, email)

```bash
# Database
DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}

# Redis
REDIS_URL=redis://redis:6379/0

# Auth.js
AUTH_SECRET=generate-a-random-secret-here
AUTH_URL=http://localhost:{host_port}

# Storage (Cloudflare R2)
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=
R2_CUSTOM_DOMAIN=

# Email (Resend)
RESEND_API_KEY=
DEFAULT_FROM_EMAIL=noreply@example.com

# Error Tracking
SENTRY_DSN=
```

---

## Integration Summaries

After completing a backend feature, generate an integration summary:
- List all new/modified endpoints with method, path, auth requirements
- Include request/response shapes as JSON examples
- Note any pagination, filtering, or ordering parameters
- Document error response shapes
- Save to `docs/integration/[feature-name].md`

This mirrors the Django convention so frontend consumers have a consistent reference.

