TheSkillz

Next.js Feature Architecture

app/ is routing. Your features live somewhere else

TheSkillz Team TheSkillz Team No reviews yet0 installsv1.0.0
Scan passed · 100/100Human reviewedOfficial · TheSkillz
☆ Star 0

Reduces the app directory to route shells and moves real code into feature folders that own their data access, with three import rules enforced by lint rather than left in a README, server-only guards that turn a leaked secret into a build error, colocated zod schemas, and why a barrel re-export drags client components into a server graph. Covers file topology, not caching semantics.

SKILL.md

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:

  1. app/ may import features/. features/ may never import app/. The moment a feature imports from a route, moving that route breaks the domain.
  2. 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.
  3. shared/ imports nothing from features/ or app/. 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 is NEXT_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 build passes, 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.tsx contains 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.

Reviews

Sign in to leave a review.

  • Be the first to review this skill.

More in coding