| name | nextjs-fullstack-bootstrap |
| description | Add full-stack backend infrastructure to a Next.js app (Drizzle, Postgres, Redis, BullMQ, Auth.js). |
| disable-model-invocation | true |
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
- Verify the base Next.js project exists with
src/app/ structure
- Install dependencies (add to
package.json)
- Create backend files from templates below (respecting blueprint tier tags)
- Update
docker-compose.yml with database and Redis services
- Update
Makefile with database and queue commands
- Update
.env.local with new environment variables
make build && make up
make db-generate && make db-migrate
- 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]
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]
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]:
export * from "./posts";
[STANDARD+]:
export * from "./users";
export * from "./posts";
src/lib/db/schema/posts.ts [ALL]
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(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull(),
content: text("content"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: ()
.()
.()
.$onUpdate( ()),
},
[().(t.)]
);
= posts.;
= posts.;
src/lib/db/schema/users.ts [STANDARD+]
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()),
});
accounts = (
,
{
: ()
.()
.( users., { : }),
: ().<>().(),
: ().(),
: ().(),
: (),
: (),
: (),
: (),
: (),
: (),
: (),
},
[
({ : [account., account.] }),
]
);
sessions = (, {
: ().(),
: ()
.()
.( users., { : }),
: (, { : }).(),
});
verificationTokens = (
,
{
: ().(),
: ().(),
: (, { : }).(),
},
[({ : [vt., vt.] })]
);
= users.;
= users.;
src/lib/auth.ts [STANDARD+]
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),
session: { strategy: "jwt" },
pages: {
signIn: "/login",
},
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: },
},
() {
(!credentials?. || !credentials?.) ;
user = db...({
: (users., credentials. ),
});
(!user?.) ;
isValid = bcrypt.(
credentials. ,
user.
);
(!isValid) ;
{ : user., : user., : user. };
},
}),
],
: {
() {
(user) token. = user.;
token;
},
() {
session.. = token. ;
session;
},
},
});
src/app/api/auth/[...nextauth]/route.ts [STANDARD+]
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
src/middleware.ts [STANDARD+]
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");
if (isApiAuth || isPublic) return;
if (isAuthPage && isLoggedIn) {
return Response.redirect(new URL("/dashboard", req.nextUrl));
}
if (!isAuthPage && !isLoggedIn) {
return Response.redirect( (, req.));
}
});
config = {
: [],
};
src/types/next-auth.d.ts [STANDARD+]
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
} & DefaultSession["user"];
}
}
src/lib/redis.ts [STANDARD+]
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]
import Redis from "ioredis";
export const queueConnection = new Redis(
process.env.REDIS_URL ?? "redis://localhost:6379",
{ maxRetriesPerRequest: null }
);
src/lib/queue/queues.ts [FULL]
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 },
},
});
src/lib/queue/workers.ts [FULL]
import { Worker } from "bullmq";
import { queueConnection } from "./client";
const emailWorker = new Worker(
"email-notifications",
async (job) => {
const { to, subject, body } = job.data;
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]
import { db } from "@/lib/db";
import { posts, type NewPost, type Post } from "@/lib/db/schema";
import { and, count, eq } from "drizzle-orm";
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() }).(posts).((posts., userId)),
]);
{ results, : totals[]. };
}
(): < | > {
db...({
: ((posts., uuid), (posts., userId)),
});
}
(): <> {
[post] = db.(posts).(data).();
post;
}
(): < | > {
[post] = db
.(posts)
.(data)
.(((posts., uuid), (posts., userId)))
.();
post;
}
(): <> {
db.(posts).(((posts., uuid), (posts., userId)));
}
src/server/actions/posts.ts [ALL]
"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(),
});
export async function createPost(
formData: FormData
): Promise<ActionResult<{ uuid: string }>> {
const session = await auth();
(!session?.?.) {
{ : , : };
}
parsed = createPostSchema.({
: formData.(),
: formData.() ?? ,
});
(!parsed.) {
{ : , : parsed..[]. };
}
post = postService.({
...parsed.,
: session..,
});
();
{ : , : { : post. } };
}
(): <> {
session = ();
(!session?.?.) {
{ : , : };
}
postService.(uuid, session..);
();
{ : , : };
}
src/app/api/v1/posts/route.ts [ALL]
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(),
});
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
{ searchParams } = (request.);
page = .(, (searchParams.()) || );
pageSize = .(
,
.(, (searchParams.()) || )
);
{ results, count } = postService.(session.., {
page,
pageSize,
});
.({
page,
count,
: .(, .(count / pageSize)),
results,
});
}
() {
session = ();
(!session?.?.) {
.({ : }, { : });
}
body = request.();
result = createPostSchema.(body);
(!result.) {
.(
{ : , : z.(result.). },
{ : }
);
}
post = postService.({
...result.,
: session..,
});
.(post, { : });
}
src/server/services/storage.ts [FULL]
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..) {
;
}
key;
}
(): <> {
s3.( ({ : bucket, : key }));
}
(): <> {
(
s3,
({ : bucket, : key }),
{ expiresIn }
);
}
src/server/services/email.ts [FULL]
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
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:
app:
depends_on:
- db
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
[STANDARD] — add db + redis
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:
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
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:
Also update the existing app service:
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
.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
.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]
{
"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)
{
"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)
{
"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"
Environment Variables (add to existing .env.local)
[MINIMAL]
DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
[STANDARD] (adds redis, auth)
DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
REDIS_URL=redis://redis:6379/0
AUTH_SECRET=generate-a-random-secret-here
AUTH_URL=http://localhost:{host_port}
SENTRY_DSN=
[FULL] (adds storage, email)
DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}
REDIS_URL=redis://redis:6379/0
AUTH_SECRET=generate-a-random-secret-here
AUTH_URL=http://localhost:{host_port}
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=
R2_CUSTOM_DOMAIN=
RESEND_API_KEY=
DEFAULT_FROM_EMAIL=noreply@example.com
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.