Nfs architecture patterns
Skill juncoding/nextjs-fullstack-starter/skills/nfs-architecture-patterns
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.From its SKILL.md
npx -y skills add juncoding/nextjs-fullstack-starter --skill nfs-architecture-patternsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
9.1 KB, ~2.0k tokens by cl100k_base, as published. Nobody here has run it
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/updateTagand 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 withimport "server-only";.- Permissions live in services. Every service method touching user-owned data takes
userIdfirst and callsrequirePermission. - 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 checkrequirePermission. - 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/revalidatePathinside services. These are page-side invalidation primitives (call them from Server Actions, not services). Inside services, useupdateTagfor tag-keyed invalidation. The distinction matters in Next.js 16 — seereferences/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 serverdirective at the top of apage.tsxor component file. That makes EVERY export a Server Action, which is almost never what you want. Server Actions go in dedicated*.actions.tsfiles insrc/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 acceptnulland 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.tsinstead ofsrc/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
Customertoschema.prisma. Migrate. - Seed permissions. Add
customers:read,customers:writeto 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 oneawait requireSession()thencustomerService.<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.tscovers the service. See thenfs-testing-patternsskill. - Run the verification gate.
pnpm verify— must be green before commit.
The module is a known shape. Don't reinvent it.
What ships with it: 7 files
58.0 KB alongside SKILL.md
references/
- caching.md6.6 KB
- error-handling.md6.5 KB
- permissions-and-audit.md6.2 KB
- route-handlers.md4.2 KB
- server-actions.md12.3 KB
- server-components-and-pages.md7.5 KB
- service-layer.md14.6 KB