agentleFS
theyoungastronautstheyoungastronauts/polaris/skills/nextjs-fullstack-bootstrap/SKILL.md

nextjs-fullstack-bootstrap

Skill4 starsChanged 3 months agoMITView on GitHub

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

  • Reads credentials
  • Deletes or force-pushes

The file

We split this skill into the rules it gives an agent

8 rules. About 0 are common, 4 are distinctive, and 4 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈4

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 4

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

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

    line 19
  2. Very specific:

    Verify the base Next.js project exists with src/app/ structure

    line 41
  3. Distinctive:

    Create backend files from templates below (respecting blueprint tier tags)

    line 43
  4. Very specific:

    Update docker-compose.yml with database and Redis services

    line 44
  5. Very specific:

    Update Makefile with database and queue commands

    line 45
  6. Very specific:

    Update .env.local with new environment variables

    line 46
  7. Distinctive:

    After completing a backend feature, generate an integration summary:

    line 1073
  8. Distinctive:

    Document error response shapes

    line 1077

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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):

PlaceholderDescriptionExample
{blueprint}Blueprint tier: minimal, standard, or fullstandard
{app-name}Existing project directory namemy-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

TierNameIncludesUse when
1minimalDrizzle + Postgres + Docker services. No Redis, no auth, no queues, no Sentry.Simple full-stack apps, prototypes, no user accounts
2standardMinimal + Redis cache + Auth.js + Sentry. No BullMQ, S3, email.Most production apps with auth
3fullStandard + 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]

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(),
    // 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+]

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+]

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+]

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");

  // 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+]

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

// 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]

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]

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]

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]

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

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]

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]

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:
  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

Makefile Additions (append to existing Makefile)

[ALL] — Database commands

# ============================================================================
# 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

# ============================================================================
# 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]

{
  "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": "^8.11",
    "tsx": "^4.19"
  }
}

Environment Variables (add to existing .env.local)

[MINIMAL]

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

[STANDARD] (adds redis, auth)

# 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)

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

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.