💻 Coding
Next.js App Router Feature Scaffold
Scaffold a Next.js App Router feature: files, RSC vs client split, loading/error/not-found, server actions, and tests from a spec.
0Reviews
Prompt
Act as a staff Next.js engineer shipping App Router features. Default to Server Components. Put use client only where it is required. Do not invent libraries that are not in Inputs. Inputs: - Feature: [Name and user-facing job] - Next.js version: [Version] - Router area: [app path] - Auth: [none / session / role] - Data: [DB, fetch, or mock] - Mutations: [server action / route handler / none] - UI: [what the page must show] - Constraints: [stack, no extra deps, etc.] - Tests: [Playwright / Vitest / none] - Known files: [existing tree scraps or none] Generate: 1. File tree: only files this feature needs, under app/ and supporting lib/. Mark server vs client vs test. 2. RSC vs client split: table of each file, why it is server or client. No client wrapper for static text. 3. Routes: page, layout (only if needed), loading.js, error.js, not-found.js. Say skip if a parent already covers it. 4. Data and mutations: server-side fetch or SQL. Server actions with revalidatePath. Zod-level validation if Inputs allow; else parseFormData with explicit checks. No invented ORM models. 5. Paste-ready code for the 3-6 core files (page, action, one client island, test). TypeScript. Short. No comments that narrate the code. 6. Empty, loading, error, unauthorized states: one line each plus which file owns it. 7. Tests: 3 cases if Tests is not none. If none, list 3 cases the author should add. 8. Do-not-do: 5 Next.js footguns avoided (shipping a client page for fetch, leaking secrets to the client, missing loading, blocking the whole layout, cache surprise). Constraints: - App Router only. No pages/ router. - Do not add auth libraries, ORMs, or CSS frameworks not in Inputs. - Keep the client island small. - If Known files conflict, prefer them over a greenfield tree.
Instructions
Replace every [bracket] with your details before running. Works on ChatGPT, Claude, and Gemini.
Examples
Example Input
Feature: public waitlist form that stores email and shows a thank-you state Next.js version: 15 Router area: app/waitlist Auth: none Data: Postgres via existing lib/db.ts query helper (tagged template). Table waitlist(email text primary key, created_at timestamptz) Mutations: server action UI: email field, submit, success line, duplicate-email error Constraints: no extra deps, Tailwind already in the app, no ORM Tests: Vitest Known files: lib/db.ts exists; app/layout.tsx exists; no waitlist folder yet
Example Output
1. File tree
app/waitlist/page.tsx (server)
app/waitlist/actions.ts (server)
app/waitlist/ui.tsx (client island)
app/waitlist/loading.tsx (server)
app/waitlist/error.tsx (client, required by App Router for error.js)
app/waitlist/page.test.ts (test)
Skip new layout: parent app/layout.tsx covers it. Skip not-found: no [id] segment.
2. RSC vs client
page.tsx server: form posts to an action, no hooks.
actions.ts server: db write, revalidatePath. Never import in client except via action prop.
ui.tsx client: useFormStatus for pending label only.
error.tsx client: error boundary contract.
loading.tsx server: static fallback.
3. Routes
page at /waitlist. loading.js yes. error.js yes. not-found skip.
4. Data and mutations
Insert into waitlist(email). Unique email is the PK. Duplicate: catch unique_violation (23505) and return {ok:false, error:"duplicate"}. revalidatePath("/waitlist"). Validate email with a single regex plus trim, lowercase. No ORM.
5. Core files
// app/waitlist/actions.ts
"use server"
import { db } from "@/lib/db"
import { revalidatePath } from "next/cache"
export async function joinWaitlist(formData: FormData) {
const email = String(formData.get("email") || "").trim().toLowerCase()
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { ok: false, error: "invalid" }
try {
await db`insert into waitlist (email) values (${email})`
} catch (e: any) {
if (e?.code === "23505") return { ok: false, error: "duplicate" }
throw e
}
revalidatePath("/waitlist")
return { ok: true }
}
// app/waitlist/page.tsx (server)
import { joinWaitlist } from "./actions"
import { Submit } from "./ui"
export default function Page() {
return (
<form action={joinWaitlist}>
<label>Email <input name="email" type="email" required /></label>
<Submit />
</form>
)
}
// app/waitlist/ui.tsx
"use client"
import { useFormStatus } from "react-dom"
export function Submit() {
const { pending } = useFormStatus()
return <button type="submit" disabled={pending}>{pending ? "Sending" : "Join waitlist"}</button>
}
6. States
Empty: form, no list. Loading: loading.tsx "Loading waitlist". Error: error.tsx retry. Unauthorized: n/a (auth none). Duplicate: action return, page should read useFormState; add that in a follow-up if you want inline errors without a client form wrapper.
7. Tests (Vitest)
- invalid email returns invalid
- happy path calls db insert
- 23505 returns duplicate
8. Do-not-do
Did not make page.tsx a client fetch. Did not put DATABASE_URL in the client island. loading.tsx added. Did not wrap the root layout in suspense for this form. Insert is a mutation: no cache read of the write.