Next.js Feature Architecture
The App Router makes the filesystem the router, and teams reasonably conclude the filesystem should therefore also be the architecture. It should not. Route structure is a URL concern that changes when marketing wants a different path; feature structure is a domain concern that should not move because a URL did.
This skill is about file topology and boundaries. It says nothing about caching semantics, Server Component rules or data-fetching APIs — read the version's own documentation for those.
The shape
src/app/ routing only
(marketing)/pricing/page.tsx imports a feature, renders it, sets metadata
api/webhooks/stripe/route.ts thin handler, delegates immediately
src/features/<feature>/
ui/ components, client and server
server/ data access, mutations, Server Actions
model/ domain types and pure logic
schema.ts zod schemas shared by action and form
src/shared/ genuinely cross-feature: ui primitives, db client, utils with a name
A page component should be short. Metadata, params, and a render of something from features/.
When a page.tsx grows past a screen, the feature is living in the route.
Boundaries, enforced
Three rules, and all three are lintable with eslint-plugin-boundaries or
import/no-restricted-paths:
app/may importfeatures/.features/may never importapp/. The moment a feature imports from a route, moving that route breaks the domain.- A feature may not import another feature. Cross-feature needs go through
shared/, or one feature exposes a narrow public surface and the dependency is made explicit and one-directional. shared/imports nothing fromfeatures/orapp/. It is the bottom of the graph.
Write these as lint rules on day one. Written down and unenforced, they hold for about six weeks.
Server and client boundaries
- Put
import "server-only"at the top of every module that touches secrets, the database, or server-side environment variables. Importing it from a client component becomes a build error rather than a secret in a JS bundle you discover later. - Use
import "client-only"for modules that genuinely need browser APIs. "use client"marks an entry point into the client graph, not a per-file annotation. Put it at the boundary component and let the tree below inherit it.- Environment variables: server-side ones are read only in
server/modules. Anything reaching the browser isNEXT_PUBLIC_and should be treated as public, because it is.
Where a Server Action lives
In features/<feature>/server/, not inline in the component and not in app/. Inline actions cannot
be tested or reused and tend to accumulate business logic inside a form.
Colocate the zod schema in schema.ts and use the same schema twice: to validate the action's input
on the server, and to type the form on the client. One definition, two uses — when a field changes,
both sides fail to compile.
Every action re-checks authorisation itself. A layout or middleware guard protects a page, not a function; a Server Action is a public endpoint with a generated name.
Barrel files and RSC
A barrel index.ts re-exporting a directory is worse here than in an ordinary bundle. Importing one
value pulls the whole barrel into the module graph — and if any module in it is a client component,
you have dragged client code into a server component's graph, inflating the bundle and sometimes
breaking the build in a way whose error message names none of this.
Import from the specific path. If a feature needs a public surface, write a small explicit
index.ts listing named exports deliberately, not export * from "./ui".
Judgement calls
- When is a feature a feature? When it owns data and has more than one screen. A single static page is not a feature; leave it in the route.
- Route groups or feature folders? Both. Route groups
(marketing)organise URLs and layouts; feature folders organise code. They are answering different questions. - Colocation inside
app/? Fine for something used by exactly one route and containing no domain logic — a page-specific chart wrapper. The moment a second route wants it, it moves.
Verification gate
-
next buildpasses, and the build log shows no unexpected client-bundle growth. - Lint boundary rules are configured and fail on a deliberately added violation. Test it.
- Every module touching secrets or the database imports
server-only. - No
page.tsxcontains data access or business logic. - Every Server Action performs its own authorisation check.
-
grep -rn "export \*" src/features/returns nothing. - Paste the build and lint output rather than describing it.