---
name: nextjs-feature-architecture
description: Reduces the Next.js app directory to routing shells and moves real code into feature folders that own their own data access, with import boundaries enforced by lint, server-only guards, colocated schemas and the barrel-file rules that keep client code out of the server graph. Use when structuring a Next.js codebase, deciding where a Server Action belongs, or untangling a sprawling app directory.
license: MIT
compatibility: Next.js App Router (14+) with TypeScript and ESLint. Complements version-specific App Router guidance rather than replacing it.
metadata:
  category: coding
  version: "1.0.0"
---

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