# Agent file: scaffold a production web application

This file is written for an AI coding agent working in a repository with its owner present. Follow the steps in order and run the check at the end of each step before starting the next. It is complete without the guide it accompanies.

Every fact about these tools here was checked against the pages under Sources on October 12, 2026. Commands, flags and dashboard labels change. If one fails, read the linked page before trying again. Do not guess.

The code below is trimmed from a working repository: a Next.js 16 client and an Express 5 API on PostgreSQL 16, with Prisma ORM 7, Zod and Vitest 5, on Node.js 24, in one pnpm 10 workspace run by Turborepo. Treat the samples as the shape to build, and the installed version of each tool as the authority on syntax.

Three parts of this skeleton have their own agent files, served by the same site as this one. This file tells you when to switch to each:

- Sign-in: `/guides/add-sign-in-with-clerk/agent.md`
- Deploy: `/guides/deploy-to-railway-custom-domain/agent.md`
- Payments: `/guides/take-payments-with-stripe/agent.md`

## Goal

The repository holds a web app and an API that build, lint, type-check and pass their tests with one command each. The database schema is created by a migration in git. The API has a health check that needs no credentials and one route that validates its input. A visitor can sign in and reach a page that a signed-out visitor cannot. A CI workflow runs the checks on every pull request. All of it runs on the local machine, on development keys.

## Preconditions

Check each one before changing anything. If one is false, stop and report it.

- Read the instruction file at the repository root (`AGENTS.md` or `CLAUDE.md`) if there is one, and follow it where it disagrees with this file. If there is none, propose one in step 1.
- Find out whether the repository already has an app. If it does, do not scaffold a second one. Report what exists and map the steps below onto it.
- Ask the human one question and wait: one Next.js app with route handlers as its API, or a separate client and API. One app is simpler. A separate API is worth it when something other than the website will call it. Steps 1 and 3 differ between the two.
- Ask whether a customer is one person or a company with several people. If it is a company, every table and query needs an organization from the start (step 6).
- Run `node --version`. Use a version the Node.js release table lists as LTS. The samples run on Node.js 24, and Vitest requires Node 22.12 or later.
- Never install a tool without a version, and read the installed major version of each tool in `package.json` before copying a sample. In particular:
  - Prisma. The samples are Prisma ORM 7. The Prisma release status page says `npm install prisma` now installs the Prisma ORM 8 command-line tool, a release candidate that does not read `schema.prisma` and has no `generate` or `migrate dev` command. For a Prisma ORM 7 project, install `prisma`, `@prisma/client` and `@prisma/adapter-pg` at one exact 7.x version and keep them locked. If the human wants version 8, stop: these samples do not apply, and its own documentation does.
  - Next.js. Version 16 names the request interception file `proxy.ts`, not `middleware.ts`, has no `next lint` command, and builds with Turbopack by default. The documentation for the installed version is bundled in `node_modules/next/dist/docs/`. Read it there.
  - Express. Version 5 forwards a rejected promise from a handler to the error handler by itself, and passes a failed `listen` to the callback. Do not add `express-async-errors`.
  - pnpm and TypeScript. The samples ran on pnpm 10 and TypeScript 5.9. Both tools have newer major versions. Tell the human which version is installed and let them choose.
- Confirm Docker is running if the database will be local.

## Environment variables

Names only. This file needs no secret. The local database credentials are the throwaway ones in the Compose file.

| Name | Where | Notes |
| --- | --- | --- |
| `DATABASE_URL` | API | Connection string the running app queries with, through the driver adapter. |
| `DIRECT_URL` | API, Prisma CLI only | Direct connection the Prisma CLI migrates over when the app connects through a pooler. `prisma.config.ts` falls back to `DATABASE_URL` when it is unset. |
| `DATABASE_URL_TEST` | API, tests only | A second database the integration tests may empty. When unset, those tests are skipped. |
| `PORT` | API and web app | The port to listen on. Hosts set it. Give each app a different local default. |
| `CORS_ORIGIN` | API | Allowed browser origins, comma separated. Only with a separate API. |
| `NEXT_PUBLIC_API_URL` | Web app | Origin of the API. Inlined at build time, so it is public. Only with a separate API. |

The sign-in, payment and hosting variables are listed in their own agent files.

## Stop and ask the human

- Any secret value. Ask for the variable to be set by the human. Never print, log, commit or echo a secret, and never ask for one to be pasted into the conversation.
- Any sign-in, account creation, plan choice or payment method.
- Anything that deletes data or cannot be undone.
- The two questions in the preconditions: one app or two, and whether customers are companies.
- Choosing or changing a major version of the framework, the ORM, the package manager or the database.
- `prisma migrate dev`, `prisma migrate reset` or any schema change against a database that is not the local development one.
- Pushing to GitHub, opening a pull request or changing repository settings. Prepare the commit and wait.
- Anything on a hosting provider. Deploying is in the Railway agent file and starts only when the human asks for it.
- Adding payments. That is the Stripe agent file, and only when the human asks for it.

## Steps

### 1. Create the workspace

For one app, the Next.js starter creates the project. Its documented defaults enable TypeScript, Tailwind CSS, ESLint, the App Router and Turbopack, and add an `AGENTS.md`:

```bash
pnpm create next-app@latest my-app --yes
cd my-app
pnpm dev
```

For a separate client and API, make a pnpm workspace:

- `pnpm-workspace.yaml` at the root, with a `packages:` list of two patterns: `apps/*` and `packages/*`. pnpm requires this file at the workspace root.
- The web app in `apps/web` (create it with the command above) and the API in `apps/api`. Shared code goes in `packages/`.
- A workspace package is depended on with the version `workspace:*`. pnpm then refuses to resolve it from anywhere but the workspace.
- A root `package.json` with a `packageManager` field that pins the pnpm version, an `engines.node` floor, and scripts `build`, `lint`, `type-check` and `test` that each call `turbo` with the same task name.
- A `turbo.json` whose `build` task has `"dependsOn": ["^build"]` and `"outputs": ["dist/**", ".next/**", "!.next/cache/**"]`, whose `lint`, `type-check` and `test` tasks have `"dependsOn": ["^build"]`, and whose `dev` task has `"cache": false` and `"persistent": true`. The `^` runs the task in a package's dependencies first.
- A shared TypeScript config with `"strict": true`.

The commands the workspace is driven with:

```bash
pnpm install                 # one lockfile for every app and package
pnpm build                   # turbo build: shared packages first, then the apps that import them
pnpm lint                    # turbo lint: the ESLint CLI, workspace by workspace
pnpm type-check              # turbo type-check: tsc --noEmit, workspace by workspace
pnpm test                    # turbo test: vitest run, unit tests only
pnpm --filter api dev        # run one workspace by its package name
```

The web app's own scripts, on Next.js 16. `lint` is the ESLint CLI because `next lint` no longer exists and `next build` does not lint. `next typegen` writes the route types the compiler needs when the generated type file is not committed:

```json
{
  "scripts": {
    "dev": "next dev -p 3200",
    "build": "next build",
    "start": "next start -p 3200",
    "lint": "eslint src",
    "type-check": "next typegen && tsc --noEmit",
    "test": "vitest run"
  }
}
```

The ESLint CLI reads a flat config. This one is `eslint.config.mjs`, a JavaScript module:

```ts
// eslint.config.mjs
import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
import nextTypescript from 'eslint-config-next/typescript';

// Next.js 16 has no "next lint". The lint script runs the ESLint CLI against this file.
export default defineConfig([
  ...nextVitals,
  ...nextTypescript,
  globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']),
]);
```

If the repository has no instruction file, write a short one now (`AGENTS.md`, or `CLAUDE.md` for Claude Code): the layout, these commands, and each rule from the later steps as you put it in place. Keep it under 200 lines.

Check: `pnpm install` finishes, then `pnpm build`, `pnpm lint` and `pnpm type-check` each exit 0.

### 2. Add the database and the first migration

Add a Compose file that runs PostgreSQL with a health check and a named volume, on a host port that does not collide with another Postgres. Use a PostgreSQL major version its versioning page lists as supported. Create a second database in the same container for tests.

Prisma ORM 7 keeps its settings in three places. First, the CLI configuration, at the root of the API package. It names the schema folder, the migrations folder, the seed command and the connection URL, and it loads `.env` itself because the CLI does not:

```ts
// prisma.config.ts, next to the API's package.json. Only the Prisma CLI reads it.
import 'dotenv/config'; // the Prisma 7 CLI does not load .env on its own

import { defineConfig } from 'prisma/config';

export default defineConfig({
  // A folder, because the models are split across prisma/models/*.prisma.
  schema: 'prisma',
  migrations: {
    path: 'prisma/migrations',
    seed: 'tsx prisma/seed.ts',
  },
  datasource: {
    // The CLI migrates over a direct connection when one is set.
    // process.env, not Prisma's env() helper: env() throws when the variable is
    // unset, and prisma generate also runs in the image build, with no database.
    url: process.env.DIRECT_URL || process.env.DATABASE_URL || '',
  },
});
```

Second, the schema. The `prisma-client` generator requires an `output` path and writes TypeScript there. The datasource block has no `url`. With a multi-file schema, `schema.prisma` and the `migrations` folder sit at the top of the folder the config names. Keep the conventions whatever the model: snake_case in the database and camelCase in code, a `deleted_at` column for soft delete on business rows, and timestamps on every table:

```prisma
// prisma/schema.prisma (Prisma ORM 7)
// The client is generated as TypeScript into src/ and compiled with the API.
// Our API builds to CommonJS, hence moduleFormat.
generator client {
  provider     = "prisma-client"
  output       = "../src/generated/prisma"
  moduleFormat = "cjs"
}

// No url here: Prisma 7 reads it from prisma.config.ts.
datasource db {
  provider = "postgresql"
  schemas  = ["app"]
}

// prisma/models/organization.prisma
model Organization {
  // app.nanoid() is a SQL function our first migration creates. Use the id
  // default Prisma documents if you do not want to write one.
  id        String    @id @default(dbgenerated("app.nanoid('co_'::text)"))
  clerkId   String    @unique @map("clerk_id")
  name      String    @map("name")
  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime  @updatedAt @map("updated_at")
  // Soft delete. Every read filters on deletedAt: null.
  deletedAt DateTime? @map("deleted_at")

  // Written only by the Stripe webhook.
  stripeCustomerId   String? @unique @map("stripe_customer_id")
  planSlug           String  @default("free") @map("plan_slug")
  subscriptionStatus String? @map("subscription_status")

  @@index([deletedAt])
  @@map("organizations")
  @@schema("app")
}
```

Third, the client. Prisma ORM 7 connects through a driver adapter, and the client is imported from the generated folder by path. Set a connection timeout: the `pg` driver has none by default.

```ts
// database/adapter.ts
import { PrismaPg } from '@prisma/adapter-pg';

// The pg driver waits forever for a connection unless it is told otherwise.
const CONNECTION_TIMEOUT_MS = 5_000;

export function createPostgresAdapter(
  connectionString: string | undefined = process.env.DATABASE_URL,
): PrismaPg {
  // Fail at boot, so a missing variable never falls back to the driver's localhost defaults.
  if (!connectionString) {
    throw new Error('DATABASE_URL is not set. The Prisma client needs a Postgres connection string.');
  }
  return new PrismaPg({ connectionString, connectionTimeoutMillis: CONNECTION_TIMEOUT_MS });
}

// Anywhere a client is made. The generated client is imported by its path.
import { PrismaClient } from './generated/prisma/client';

const prisma = new PrismaClient({ adapter: createPostgresAdapter() });
```

Two commands apply migrations. `migrate dev` writes and applies a migration and is for development only. `migrate deploy` applies the migrations already in git and is for every other environment:

```bash
# Postgres 16 in a container. --wait returns once its health check passes.
docker compose up -d --wait

# Development only: prisma migrate dev, then prisma generate.
# It writes a migration from the schema change and applies it.
pnpm --filter api db:migrate

# Everywhere else (CI, staging, production): prisma migrate deploy.
# It applies the migrations already in git and nothing more.
pnpm --filter api db:migrate:deploy
```

Put the commands behind package scripts so nobody types the wrong one. On Prisma ORM 7 `migrate dev` does not generate the client and does not seed, so the scripts do both explicitly:

```json
{
  "scripts": {
    "build": "prisma generate && tsc && tsc-alias",
    "lint": "eslint src",
    "type-check": "tsc --noEmit",
    "test": "vitest run",
    "test:int": "vitest run --config vitest.int.config.ts",
    "db:generate": "prisma generate",
    "db:migrate": "prisma migrate dev && prisma generate",
    "db:migrate:deploy": "prisma migrate deploy",
    "db:seed": "prisma db seed"
  }
}
```

Commit the migration folder. Add the generated client folder and `.env` to `.gitignore`, exclude the generated folder from linting, and commit a `.env.example` with names and the local throwaway values only.

Check: on a fresh database, the deploy script creates every table, and running it a second time reports nothing to apply. `pnpm build` then writes the generated client and compiles.

### 3. Add the health check and assemble the API

The health route must not touch the database, the sign-in provider or any other service. It has to answer when a dependency is down, because a host uses it to decide whether a deploy started.

```ts
// health.routes.ts
import { Router } from 'express';

/** Paths the auth middleware skips. Each needs a route, or it falls through to the auth guard. */
export const HEALTH_PATHS: readonly string[] = ['/api/v1/health', '/health'];

// No database, no auth, no upstream calls. It answers only "is the process up?".
export function createHealthRouter(): Router {
  const router = Router();
  router.get([...HEALTH_PATHS], (_req, res) => {
    res.json({ status: 'ok', timestamp: new Date().toISOString(), service: 'api' });
  });
  return router;
}
```

With one Next.js app, this is a route handler at `app/api/health/route.ts` that returns `{ status: "ok" }` and is excluded from the proxy matcher.

Then mount everything in this order. In Express a route is public or protected by where it sits, so the order is the security model. The sample puts in one file a sequence the source repository spreads over three, so the names it does not define (`prisma`, `waitForDatabase`, `rateLimiter`, `requireAuth`, the routers and `errorHandler`) are yours to write:

```ts
// src/index.ts, trimmed. Express 5.
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';

const app = express();

async function start(): Promise<void> {
  // Raw-body parsers for webhook paths are mounted here, before express.json().

  // A waking database refuses connections for a few seconds. Retry, then give up.
  await waitForDatabase(
    async () => {
      await prisma.$queryRaw`SELECT 1`;
    },
    { attempts: 10, delayMs: 3000, log: console.warn },
  );

  app.use(express.json());
  app.use(helmet());
  app.use(cors({ origin: corsOrigins, credentials: true }));
  app.use('/api', rateLimiter);

  // 1. Public: the health check and signed webhooks. Nothing here needs a session.
  app.use(createHealthRouter());
  // 2. The auth guard. Ours answers 401. Everything mounted below it needs a verified session.
  app.use(requireAuth());
  // 3. Feature routers. Organization-scoped ones sit behind a membership check (step 5).
  app.use('/api/v1/users', createMeRouter(prisma));
  app.use('/api/v1/:clerkOrgId', orgRouter);
  // 4. The error handler, last.
  app.use(errorHandler);

  const port = parseInt(process.env.PORT ?? '3201');
  app.listen(port, (error?: Error) => {
    // Express 5 passes a failed listen (a port in use, for example) to this
    // callback instead of throwing. Exit so the host restarts the process.
    if (error) {
      console.error(`[api] could not listen on port ${port}:`, error);
      process.exit(1);
    }
    console.log(`[api] listening on port ${port}`);
  });
}

// No database after the retries: exit non-zero and let the host restart the process.
start().catch((error) => {
  console.error('Unhandled error during startup:', error);
  process.exit(1);
});
```

Do not add the auth guard yet if sign-in is not installed. Leave the comment marking where it goes and mount it in step 5.

Check: start the API and run `curl -i localhost:3201/api/v1/health` (use your own port). It returns 200 with no credentials.

### 4. Add one validated route and the error handler

Build one real route the way every later route will be built: a schema for the request body, a type derived from the schema, an async handler that parses before it reads, and no try and catch around the parse.

```ts
// organizations.schemas.ts
import { z } from 'zod';

// Only the fields a caller may change. Ids, slugs and counts are not accepted.
export const updateOrganizationSchema = z.object({
  name: z.string().min(1).max(200).optional(),
  imageUrl: z.string().url().optional().nullable(),
  primaryColor: z.string().max(20).optional().nullable(),
});

// The type comes from the schema, so the two cannot drift apart.
export type UpdateOrganization = z.infer<typeof updateOrganizationSchema>;

// organizations.controller.ts
async updateCurrent(req: Request, res: Response): Promise<void> {
  // The organization comes from the verified session, never from the URL or the body.
  const { organizationId } = getAuthContext(req);

  // Throws a ZodError on bad input. Express 5 hands it to the error handler, which answers 400.
  const data = updateOrganizationSchema.parse(req.body);

  const org = await this.model.updateForOrg(organizationId, data);
  if (!org) {
    sendError(res, 404, 'Organization not found');
    return;
  }

  sendData(res, formatOrgResponse(org));
}
```

`.parse` returns a typed copy of valid input and throws a `ZodError` otherwise. The sample was written on Zod 3, and `.parse` and `z.infer` are the same in Zod 4. One error handler, mounted after every router, turns that error into a 400 and turns anything unexpected into a 500 that tells the client nothing. It matches the error by name and shape so that it works when a workspace holds more than one copy of Zod:

```ts
// utils/zod-error.ts
/** The part of a Zod validation error the handler reports. */
interface ZodErrorLike {
  readonly name: 'ZodError';
  readonly issues: readonly unknown[];
}

// Matches a ZodError from any installed copy of zod: every major names it and carries issues.
function isZodError(err: unknown): err is ZodErrorLike {
  return err instanceof Error && err.name === 'ZodError' && Array.isArray((err as { issues?: unknown }).issues);
}

// Tell the caller which field failed and why, and nothing else. A Zod issue can
// carry the value that was submitted, so issues are never sent or logged whole.
function validationDetails(issues: readonly unknown[]) {
  return issues.map((issue) => ({
    path: pathOf(issue),
    code: stringField(issue, 'code') ?? 'unknown',
    message: stringField(issue, 'message') ?? 'Invalid value',
  }));
}

// backend.ts. Mounted last, after every router. Four arguments make it an error handler.
app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
  if (isZodError(err)) {
    // The log gets the field and the kind of problem. Never the request body.
    apiLogger.warn('Validation error', { errors: validationLogEntries(err.issues), path: req.path, method: req.method });
    return res.status(400).json({
      success: false,
      code: 'VALIDATION_ERROR',
      message: 'Validation failed',
      details: validationDetails(err.issues),
    });
  }

  if (err.name === 'AuthenticationError') {
    return res.status(401).json({ success: false, code: 'UNAUTHORIZED', message: 'Authentication required' });
  }

  // Log the real error on the server. Tell the client nothing about it.
  logger.error('Unhandled route error', { error: err.message, stack: err.stack, method: req.method, path: req.path });
  res.status(500).json({ success: false, code: 'INTERNAL_SERVER_ERROR', message: 'Internal server error' });
});
```

Use one response envelope for the whole API, for example `{ success: true, data }` and `{ success: false, code, message }`, sent through two helper functions. Give each feature one folder with the same files: routes, controller, model, schemas, and the tests beside them.

Check: a request with an invalid body returns 400 with a path, a code and a message for each failed field and nothing else from the submitted input, and a request with a valid body returns the envelope. Neither response contains a stack trace, and the log line for the failure does not contain the request body.

### 5. Add sign-in

Switch to the Clerk agent file at `/guides/add-sign-in-with-clerk/agent.md` and follow it from its preconditions through its step that authenticates a separate API. It covers the SDK, the proxy file, the sign-in controls, and the session check on pages and on the API. Stay on a development instance. Leave its webhook step until a table needs user rows.

When you return here, the auth guard from step 3 is mounted, and the health check still sits above it.

Check: sign up and sign in work in the browser, and `curl -i localhost:3201/api/v1/users/me` with no token returns 401 (use your own port and route). The health check still returns 200 with no credentials.

### 6. Protect a page, and scope data to the organization

Each protected page checks the session itself. Do not rely on the proxy or a layout for it. Keep pages thin: a server component that checks, then renders a client component.

```tsx
// app/(admin)/billing/page.tsx
// Rendered on each request, which also lets the app build with no auth keys present.
export const dynamic = 'force-dynamic';

import { auth } from '@clerk/nextjs/server';

import { BillingView } from '@/components/pages/BillingView';

export default async function BillingPage() {
  // Signed out: redirects to the sign-in screen.
  await auth.protect();

  return <BillingView />;
}
```

If customers are companies, add the tenant now. Give every customer table an `organization_id`, take the organization from the verified session in every query, and put organization-scoped routes behind one middleware that checks membership and answers 404 to a non-member. Organizations is switched on for each Clerk instance. The Clerk Organizations page gives agents the command `npx clerk@latest enable orgs` for that. Ask before running it:

```ts
// Mounted on /api/v1/:clerkOrgId, after the auth guard. The URL selects the
// organization. It never grants access to it.
export function createOrgUrlScope(prisma: PrismaClient) {
  return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    const clerkOrgId = req.params['clerkOrgId'];
    // req.provisioned is set by the auth middleware from the verified session.
    const userId = req.provisioned?.user?.id as string | undefined;

    // Express 5 types a param as string | string[] (wildcards are arrays).
    // A named segment is a string, so anything else is no organization.
    if (typeof clerkOrgId !== 'string' || !clerkOrgId || !userId) {
      sendError(res, 404, 'Organization not found');
      return;
    }

    const membership = await prisma.userOrganization.findFirst({
      where: { userId, deletedAt: null, organization: { clerkId: clerkOrgId, deletedAt: null } },
      select: { organization: { select: { id: true, clerkId: true } } },
    });

    // 404, not 403: a non-member must not learn that the organization exists.
    if (!membership) {
      sendError(res, 404, 'Organization not found');
      return;
    }

    req.provisioned = { ...req.provisioned, organization: membership.organization };
    next();
  };
}
```

If each customer is one person, skip the middleware and scope every query by the user id from the session instead.

Check: the protected page redirects a signed-out browser to sign-in and renders for a signed-in one. With organizations, a signed-in member of one organization gets 404 from a route under another organization's id.

### 7. Write the first tests

Start with the tests that guard the skeleton. Test files need `.test.` or `.spec.` in their name, and `vitest run` runs them once. The health check first:

```ts
// health.routes.test.ts
import { describe, it, expect } from 'vitest';
import express from 'express';
import supertest from 'supertest';
import { createHealthRouter, HEALTH_PATHS } from './health.routes';

describe('createHealthRouter', () => {
  it.each(HEALTH_PATHS)('answers ok on %s without auth', async (path) => {
    const app = express();
    app.use(createHealthRouter());

    const res = await supertest(app).get(path);

    expect(res.status).toBe(200);
    expect(res.body).toMatchObject({ status: 'ok', service: 'api' });
  });
});
```

Then add, in this order: a test that the validated route answers 400 to a bad body and does not call the model; a test that lists every page and route handler and fails when one is not marked public or protected; and, with organizations, a test that calls each scoped route as a member of another organization and expects 404.

Keep two suites. Unit tests need no keys and no database, and run with `pnpm test`. Integration tests run the real routes against the test database from step 2 and are skipped when `DATABASE_URL_TEST` is unset. Do not mock the database in integration tests.

Check: `pnpm test` exits 0 with no environment variables set.

### 8. Add CI

Add a GitHub Actions workflow that runs the four checks on every pull request and on pushes to the main branch. Keep the push trigger: a host that waits for CI before deploying needs the workflow to run on push. The source repository does not run this workflow itself. It is assembled from the setup steps of a workflow that repository does run and the four commands above:

```yaml
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # No pnpm version here: package.json pins it in the packageManager field.
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      # Build first: it writes the generated Prisma client that type-check needs.
      - run: pnpm build
      - run: pnpm lint
      - run: pnpm type-check
      - run: pnpm test
```

Set `node-version` to the version the app runs on. The two setup actions are the ones the pnpm 10 continuous integration page shows. The current pnpm page shows a single newer action. Read the page for the installed pnpm version and use the form it documents if this one fails. The checks must pass with no secrets configured. If the build needs a key, fix the build. When integration tests join CI, GitHub documents a PostgreSQL service container with a health check for the job to wait on.

Check: the four commands exit 0 locally from a clean clone. Then give the human the commit to push, and the workflow run passes on the pull request.

### 9. Hand off the deploy and the payments

Stop here unless the human asks for more. To deploy, with a staging environment beside production, switch to the Railway agent file at `/guides/deploy-to-railway-custom-domain/agent.md`. To add billing, after sign-in works and the app has a public address, switch to the Stripe agent file at `/guides/take-payments-with-stripe/agent.md`.

Before either, the skeleton should already have what they need: a health check outside the auth middleware and the proxy matcher, migrations behind a deploy script, a `prisma.config.ts` that works with no database present so the image can build, every secret read from an environment variable, and columns for the payment provider's customer id, the plan and the subscription status that no request handler writes.

Check: you have told the human which of the two hand-offs is still open.

## Done when

- `pnpm build`, `pnpm lint`, `pnpm type-check` and `pnpm test` each exit 0 from a clean clone with no secrets set.
- A fresh database reaches the current schema from the migrations in git alone.
- The health check returns 200 with no credentials and touches no other service.
- A bad request body returns 400, and an unexpected error returns a 500 with no detail in the body.
- A signed-out visitor cannot load the protected page or call a protected route.
- With organizations, a test shows that one organization cannot read another's rows.
- The CI workflow is committed and runs the same four commands.
- No secret value is in the repository or in your output.
- The human has a list of what you created, the exact version of each tool you installed, and what is left: deploy, payments, email, analytics and error tracking.

## Sources

- [Next.js: Installation](https://nextjs.org/docs/app/getting-started/installation)
- [Next.js: How to upgrade to version 16](https://nextjs.org/docs/app/guides/upgrading/version-16)
- [Next.js: proxy.js file convention](https://nextjs.org/docs/app/api-reference/file-conventions/proxy)
- [Next.js: ESLint configuration](https://nextjs.org/docs/app/api-reference/config/eslint)
- [Next.js: TypeScript configuration](https://nextjs.org/docs/app/api-reference/config/typescript)
- [TypeScript: strict](https://www.typescriptlang.org/tsconfig/strict.html)
- [TypeScript: Download](https://www.typescriptlang.org/download/)
- [Node.js: Releases](https://nodejs.org/en/about/previous-releases)
- [Express: Migrating to Express 5](https://expressjs.com/en/guide/migrating-5/)
- [Express: Error handling](https://expressjs.com/en/guide/error-handling/)
- [Express: Production best practices, security](https://expressjs.com/en/advanced/best-practice-security/)
- [PostgreSQL: Versioning policy](https://www.postgresql.org/support/versioning/)
- [Prisma: Release status](https://www.prisma.io/docs/orm/release-status)
- [Prisma: Upgrade to Prisma ORM 7](https://www.prisma.io/docs/guides/upgrade-prisma-orm/v7)
- [Prisma ORM 7: Schema location](https://www.prisma.io/docs/orm/v7/prisma-schema/overview/location)
- [Prisma ORM 7: Generators](https://www.prisma.io/docs/orm/v7/prisma-schema/overview/generators)
- [Prisma ORM 7: Development and production](https://www.prisma.io/docs/orm/v7/prisma-migrate/workflows/development-and-production)
- [Zod: Basic usage](https://zod.dev/basics)
- [pnpm: Installation](https://pnpm.io/installation)
- [pnpm 10: Workspace](https://pnpm.io/10.x/workspaces)
- [pnpm 10: Filtering](https://pnpm.io/10.x/filtering)
- [pnpm 10: Continuous integration](https://pnpm.io/10.x/continuous-integration)
- [pnpm: Continuous integration](https://pnpm.io/continuous-integration)
- [Turborepo: Configuring tasks](https://turborepo.dev/docs/crafting-your-repository/configuring-tasks)
- [Vitest: Getting started](https://vitest.dev/guide/)
- [Docker: docker compose up](https://docs.docker.com/reference/cli/docker/compose/up/)
- [GitHub: Creating PostgreSQL service containers](https://docs.github.com/en/actions/tutorials/use-containerized-services/create-postgresql-service-containers)
- [Claude Code: How Claude remembers your project](https://code.claude.com/docs/en/memory)
- [Clerk: Organizations overview](https://clerk.com/docs/guides/organizations/overview)
- [Railway: GitHub autodeploys](https://docs.railway.com/deployments/github-autodeploys)
