Architecture patterns for ongoing development
For projects already scaffolded with nextjs-fullstack-starter. Explains the patterns to follow when adding features. Companion to the nfs-scaffold-app skill which only handles initial setup.
Use this skill when
- Adding a new business module (e.g.
customer, order, invoice).
- Writing a new page, Server Action, or route handler.
- Deciding whether something belongs in a page, a Server Action, a route handler, an MCP tool, or a cron job.
- Wiring permissions on a new resource.
- Structuring a service that touches multiple modules.
- Caching with
cacheTag / updateTag and not sure when to use which.
- Handling errors at any layer.
- Reviewing whether a PR follows project conventions.
The four-rule cheat sheet
src/app/ is a thin delivery layer. No business logic. No DB queries. Just: validate, call a service, return.
src/server/ is the entire backend. Every file starts with import "server-only";.
- Permissions live in services. Every service method touching user-owned data takes
userId first and calls requirePermission.
- Audit calls live in services, inside the same transaction as the mutation.
If you remember nothing else, remember these four.
Reference index
Read the file matching your task:
| Doing this... |
Read this |
| Creating a new business module (service, schema, types) |
references/service-layer.md |
| Writing a Server Component page that reads data |
references/server-components-and-pages.md |
| Writing a Server Action for a write/mutation |
references/server-actions.md |
| Caching reads, invalidating after writes |
references/caching.md |
| Wiring auth / RBAC for a new resource |
references/permissions-and-audit.md |
| Adding a REST endpoint (webhook, third-party callable, file download) |
references/route-handlers.md |
| Throwing / catching errors at any layer |
references/error-handling.md |
The delivery-layer matrix
When you have new functionality, decide which delivery layer it lives in:
| Caller |
Delivery layer |
| The app's own UI — reading data |
Async Server Component page in src/app/(dashboard)/<feature>/page.tsx → service |
| The app's own UI — writing data |
Server Action in src/server/actions/<feature>.actions.ts → service |
| An AI client (Claude Desktop, Cursor) |
MCP tool in src/server/mcp/tools/ (wraps the same service) |
| A webhook (Stripe, Resend, Svix-signed) |
Route handler at src/app/api/webhooks/<provider>/route.ts |
| A scheduled job |
Cron registration in src/server/jobs/, kicked by instrumentation.ts |
| A third-party that needs REST |
Route handler at src/app/api/v1/<resource>/route.ts |
| File upload / download |
Route handler (Web Streams API) |
| A test |
Direct service call with mocked Prisma, or createCaller-style test harness if you build one |
All of these end up calling the same service method — only the wrapper layer differs.
Server Component vs. client component
Default: Server Component. Add "use client" only when you need:
- React hooks (
useState, useEffect, etc.)
- Browser-only APIs (
window, document, localStorage, IntersectionObserver)
- Event handlers (
onClick, onChange, onSubmit — though <form action> works without client JS)
- Third-party libraries that explicitly need a client (cmdk, framer-motion, etc.)
When you need client interactivity over server-fetched data, fetch on the server and pass data in as a prop:
// page.tsx — Server Component, fetches data
import { CustomerFilters } from "./_components/filters"; // client component
export default async function Page() {
const session = await requireSession();
const customers = await customerService.list(session.user.id, {});
return <CustomerFilters initial={customers} />;
}
Don't fetch data inside client components by spinning up a route handler just to feed them — that's reintroducing the JSON layer you came here to avoid.
Server Action vs. route handler
Both are POST handlers. Pick by caller:
|
Server Action |
Route handler |
| Caller |
The app's own UI (forms / buttons) |
Anything else — webhooks, mobile, AI, scripts |
| URL |
None — invoked by reference |
Real URL — /api/... |
| Body shape |
FormData or any serializable JS value |
Arbitrary HTTP body |
| Use revalidate? |
Yes — revalidatePath / updateTag after the mutation |
No — the caller manages their own state |
| Best for |
Forms, button-click mutations, anything triggered by the UI |
Anything triggered by something external |
If you find yourself writing fetch("/api/customers", { method: "POST" }) from inside the app's own client component, stop — that's the Server Action's job. The fetch + JSON + handler pattern undoes the type-safety you came for.
Anti-patterns to refuse
- DB queries in pages or Server Actions. The page is delivery; Prisma is service. If a page has
db.customer.findMany, move it to the service.
- Permission checks in pages or Server Actions. Same reason — easy to forget, security-critical, belongs with the data layer. Pages check
requireSession; services check requirePermission.
- Server Actions that do business logic inline. The action validates and calls; the service does the work. If your action body is >20 lines, the logic belongs in a service.
- Calling
fetch('/api/...') from a client component when a Server Action exists. Server Actions exist for this exact case. Use them.
revalidateTag / revalidatePath inside services. These are page-side invalidation primitives (call them from Server Actions, not services). Inside services, use updateTag for tag-keyed invalidation. The distinction matters in Next.js 16 — see references/caching.md.
- Importing client libraries (React Query, zustand, etc.) into Server Components. They'll crash at build time, but more subtly: they signal that someone is trying to manage server-fetched state in the client when the page should just refetch.
use server directive at the top of a page.tsx or component file. That makes EVERY export a Server Action, which is almost never what you want. Server Actions go in dedicated *.actions.ts files in src/server/actions/.
When to break the rules
The rules exist because they pay rent — they make the codebase navigable, secure, and refactorable. Breaking them is allowed when the break itself is the cheaper option, and you're explicit about it.
Examples of legit breaks:
- A service method that reads but doesn't mutate and is called from a public, unauthenticated route handler (e.g. a supplier portal). The
userId-first signature is awkward there — use a sentinel or accept null and document why.
- A read trivially hot enough to inline in a Server Component (e.g. a header count). Add a comment, move on.
- A
'use server' action file co-located next to a page (_actions.ts instead of src/server/actions/) when the action is genuinely page-local and won't be reused. Fine, but think about whether reuse will sneak in.
When you break a rule, leave a one-line comment explaining why. Future you, or the next Claude session, needs to know it was intentional.
Workflow — adding a new module
A typical "add a customer module" session looks like:
- Prisma model. Add
Customer to schema.prisma. Migrate.
- Seed permissions. Add
customers:read, customers:write to your permission seed, attach to relevant roles.
- Service module folder.
src/server/modules/customer/ — .service.ts + .schema.ts. Copy the shape from _example/.
- Server Actions.
src/server/actions/customer.actions.ts — create / update / delete actions, each one await requireSession() then customerService.<method>(session.user.id, ...).
- Pages.
src/app/(dashboard)/customers/ — page.tsx (list), [id]/page.tsx (detail), new/page.tsx (form), [id]/edit/page.tsx (edit form). Each one async, calls the service.
- Wire sidebar. Add the Customers entry in
src/components/layout/sidebar.tsx.
- Tests.
customer.service.spec.ts covers the service. See the nfs-testing-patterns skill.
- Run the verification gate.
pnpm verify — must be green before commit.
The module is a known shape. Don't reinvent it.
1---2name: nfs-architecture-patterns3description: Reference patterns for ongoing development on a Next.js fullstack project scaffolded with nextjs-fullstack-starter — Server Components for reads, Server Actions for writes, services in src/server/modules/. Use this whenever adding a new module, writing a new page or Server Action, deciding between Server Action vs route handler vs MCP tool, wiring permissions, structuring services, handling errors, caching with cacheTag / updateTag, or making any architectural decision in a project that was bootstrapped with this plugin. Triggers on phrases like 'add a new module', 'create a Server Action', 'where should this logic go', 'follow project conventions', 'how do I invalidate the cache', 'should this be a page or an action', or any 'how do I do X in this project' question.4---56# Architecture patterns for ongoing development78For projects already scaffolded with `nextjs-fullstack-starter`. Explains the patterns to follow when adding features. Companion to the `nfs-scaffold-app` skill which only handles initial setup.910## Use this skill when1112- Adding a new business module (e.g. `customer`, `order`, `invoice`).13- Writing a new page, Server Action, or route handler.14- Deciding whether something belongs in a page, a Server Action, a route handler, an MCP tool, or a cron job.15- Wiring permissions on a new resource.16- Structuring a service that touches multiple modules.17- Caching with `cacheTag` / `updateTag` and not sure when to use which.18- Handling errors at any layer.19- Reviewing whether a PR follows project conventions.2021## The four-rule cheat sheet22231. **`src/app/` is a thin delivery layer.** No business logic. No DB queries. Just: validate, call a service, return.242. **`src/server/` is the entire backend.** Every file starts with `import "server-only";`.253. **Permissions live in services.** Every service method touching user-owned data takes `userId` first and calls `requirePermission`.264. **Audit calls live in services**, inside the same transaction as the mutation.2728If you remember nothing else, remember these four.2930## Reference index3132Read the file matching your task:3334| Doing this... | Read this |35|---|---|36| Creating a new business module (service, schema, types) | `references/service-layer.md` |37| Writing a Server Component page that reads data | `references/server-components-and-pages.md` |38| Writing a Server Action for a write/mutation | `references/server-actions.md` |39| Caching reads, invalidating after writes | `references/caching.md` |40| Wiring auth / RBAC for a new resource | `references/permissions-and-audit.md` |41| Adding a REST endpoint (webhook, third-party callable, file download) | `references/route-handlers.md` |42| Throwing / catching errors at any layer | `references/error-handling.md` |4344## The delivery-layer matrix4546When you have new functionality, decide which delivery layer it lives in:4748| Caller | Delivery layer |49|---|---|50| The app's own UI — reading data | **Async Server Component page** in `src/app/(dashboard)/<feature>/page.tsx` → service |51| The app's own UI — writing data | **Server Action** in `src/server/actions/<feature>.actions.ts` → service |52| An AI client (Claude Desktop, Cursor) | **MCP tool** in `src/server/mcp/tools/` (wraps the same service) |53| A webhook (Stripe, Resend, Svix-signed) | **Route handler** at `src/app/api/webhooks/<provider>/route.ts` |54| A scheduled job | **Cron registration** in `src/server/jobs/`, kicked by `instrumentation.ts` |55| A third-party that needs REST | **Route handler** at `src/app/api/v1/<resource>/route.ts` |56| File upload / download | **Route handler** (Web Streams API) |57| A test | **Direct service call** with mocked Prisma, or `createCaller`-style test harness if you build one |5859All of these end up calling the **same service method** — only the wrapper layer differs.6061## Server Component vs. client component6263Default: **Server Component**. Add `"use client"` only when you need:6465- React hooks (`useState`, `useEffect`, etc.)66- Browser-only APIs (`window`, `document`, `localStorage`, `IntersectionObserver`)67- Event handlers (`onClick`, `onChange`, `onSubmit` — though `<form action>` works without client JS)68- Third-party libraries that explicitly need a client (cmdk, framer-motion, etc.)6970When you need client interactivity over server-fetched data, **fetch on the server and pass data in as a prop**:7172```tsx73// page.tsx — Server Component, fetches data74import { CustomerFilters } from "./_components/filters"; // client component7576export default async function Page() {77 const session = await requireSession();78 const customers = await customerService.list(session.user.id, {});79 return <CustomerFilters initial={customers} />;80}81```8283Don't fetch data inside client components by spinning up a route handler just to feed them — that's reintroducing the JSON layer you came here to avoid.8485## Server Action vs. route handler8687Both are POST handlers. Pick by caller:8889| | Server Action | Route handler |90|---|---|---|91| Caller | The app's own UI (forms / buttons) | Anything else — webhooks, mobile, AI, scripts |92| URL | None — invoked by reference | Real URL — `/api/...` |93| Body shape | `FormData` or any serializable JS value | Arbitrary HTTP body |94| Use revalidate? | Yes — `revalidatePath` / `updateTag` after the mutation | No — the caller manages their own state |95| Best for | Forms, button-click mutations, anything triggered by the UI | Anything triggered by something external |9697If you find yourself writing `fetch("/api/customers", { method: "POST" })` from inside the app's own client component, **stop** — that's the Server Action's job. The fetch + JSON + handler pattern undoes the type-safety you came for.9899## Anti-patterns to refuse100101- **DB queries in pages or Server Actions.** The page is delivery; Prisma is service. If a page has `db.customer.findMany`, move it to the service.102- **Permission checks in pages or Server Actions.** Same reason — easy to forget, security-critical, belongs with the data layer. Pages check `requireSession`; services check `requirePermission`.103- **Server Actions that do business logic inline.** The action validates and calls; the service does the work. If your action body is >20 lines, the logic belongs in a service.104- **Calling `fetch('/api/...')` from a client component when a Server Action exists.** Server Actions exist for this exact case. Use them.105- **`revalidateTag` / `revalidatePath` inside services.** These are page-side invalidation primitives (call them from Server Actions, not services). Inside services, use `updateTag` for tag-keyed invalidation. The distinction matters in Next.js 16 — see `references/caching.md`.106- **Importing client libraries (React Query, zustand, etc.) into Server Components.** They'll crash at build time, but more subtly: they signal that someone is trying to manage server-fetched state in the client when the page should just refetch.107- **`use server` directive at the top of a `page.tsx` or component file.** That makes EVERY export a Server Action, which is almost never what you want. Server Actions go in dedicated `*.actions.ts` files in `src/server/actions/`.108109## When to break the rules110111The rules exist because they pay rent — they make the codebase navigable, secure, and refactorable. Breaking them is allowed when the break itself is the cheaper option, and you're explicit about it.112113Examples of legit breaks:114115- A service method that reads but doesn't mutate **and** is called from a public, unauthenticated route handler (e.g. a supplier portal). The `userId`-first signature is awkward there — use a sentinel or accept `null` and document why.116- A read trivially hot enough to inline in a Server Component (e.g. a header count). Add a comment, move on.117- A `'use server'` action file co-located next to a page (`_actions.ts` instead of `src/server/actions/`) when the action is genuinely page-local and won't be reused. Fine, but think about whether reuse will sneak in.118119When you break a rule, **leave a one-line comment** explaining why. Future you, or the next Claude session, needs to know it was intentional.120121## Workflow — adding a new module122123A typical "add a customer module" session looks like:1241251. **Prisma model.** Add `Customer` to `schema.prisma`. Migrate.1262. **Seed permissions.** Add `customers:read`, `customers:write` to your permission seed, attach to relevant roles.1273. **Service module folder.** `src/server/modules/customer/` — `.service.ts` + `.schema.ts`. Copy the shape from `_example/`.1284. **Server Actions.** `src/server/actions/customer.actions.ts` — create / update / delete actions, each one `await requireSession()` then `customerService.<method>(session.user.id, ...)`.1295. **Pages.** `src/app/(dashboard)/customers/` — `page.tsx` (list), `[id]/page.tsx` (detail), `new/page.tsx` (form), `[id]/edit/page.tsx` (edit form). Each one async, calls the service.1306. **Wire sidebar.** Add the Customers entry in `src/components/layout/sidebar.tsx`.1317. **Tests.** `customer.service.spec.ts` covers the service. See the `nfs-testing-patterns` skill.1328. **Run the verification gate.** `pnpm verify` — must be green before commit.133134The module is a known shape. Don't reinvent it.