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

0.0
0Reviews
P
August 24, 2026

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.

Reviews (0)

Please login to leave a review.
Loading reviews...