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

- Skill: `juncoding/nfs-architecture-patterns` (Agent Skill, multi-file: 8 files)
- Install (CLI): `npx skillmds@latest add juncoding/nfs-architecture-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/juncoding/nfs-architecture-patterns/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: juncoding (https://skillmd.com/u/juncoding)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/juncoding/nfs-architecture-patterns

---


# 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

1. **`src/app/` is a thin delivery layer.** No business logic. No DB queries. Just: validate, call a service, return.
2. **`src/server/` is the entire backend.** Every file starts with `import "server-only";`.
3. **Permissions live in services.** Every service method touching user-owned data takes `userId` first and calls `requirePermission`.
4. **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**:

```tsx
// 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:

1. **Prisma model.** Add `Customer` to `schema.prisma`. Migrate.
2. **Seed permissions.** Add `customers:read`, `customers:write` to your permission seed, attach to relevant roles.
3. **Service module folder.** `src/server/modules/customer/` — `.service.ts` + `.schema.ts`. Copy the shape from `_example/`.
4. **Server Actions.** `src/server/actions/customer.actions.ts` — create / update / delete actions, each one `await requireSession()` then `customerService.<method>(session.user.id, ...)`.
5. **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.
6. **Wire sidebar.** Add the Customers entry in `src/components/layout/sidebar.tsx`.
7. **Tests.** `customer.service.spec.ts` covers the service. See the `nfs-testing-patterns` skill.
8. **Run the verification gate.** `pnpm verify` — must be green before commit.

The module is a known shape. Don't reinvent it.

