Feature-First Clean Architecture for Next.js

A folder structure is a theory about how change happens. Here is the one I use in Next.js, what the domain layer actually buys, and where clean architecture turns into ceremony.

Feature-first clean architecture for Next.js — Essay 01 in the Architecture notes series, set in Instrument Sans on a warm paper field.
Feature-first clean architecture for Next.js — Essay 01 in the Architecture notes series, set in Instrument Sans on a warm paper field.
On this page

The first question in most Next.js projects is not “where does this component go?” It is “what kind of change are we expecting?” A folder structure is a theory about that change. If features change together, the folders should change together. If the database changes independently of the screen, there should be a seam between them.

I have shipped Next.js apps with a flat components/ folder, with a lib/ folder that became a second application, and with a full clean-architecture stack that added four mappings for every field. The structure I keep coming back to is smaller than the textbook and stricter than the default: feature-first at the top, layered inside each feature.

The folder is a theory

The default Next.js structure is organised by technical role. app/, components/, lib/, hooks/, types/. That works while the project is small, because every change touches one of everything. It stops working when the product has three real domains and a change to billing requires opening six top-level folders.

Feature-first starts from the change instead of the artifact:

src/features/billing/
├── schema.ts          # zod schemas + inferred types
├── use-case.ts        # one business transaction
├── repository.ts      # persistence, scoped to this feature
├── ui/
│   ├── invoice-table.tsx
│   └── pay-button.tsx
└── index.ts           # the public surface

Everything the feature needs is in one place. The only thing other features may import is index.ts. That single rule does more for maintainability than any amount of folder naming.

What “clean” actually buys you

Clean architecture is usually drawn as concentric circles: entities, use cases, interface adapters, frameworks. The diagram is useful, but the value is not the number of circles. The value is dependency direction. Inner layers define the contracts; outer layers implement them. The database is a detail. The framework is a detail. The UI is a detail.

In a Next.js app, the practical version is smaller:

  • Route handlers and server actions own HTTP, auth, validation at the edge, and response shape.
  • Use cases own one business transaction and the order in which it happens.
  • Domain owns the rules and types that remain true even if the framework disappears.
  • Repositories own persistence details and expose a narrow interface to the use case.
A request crossing five boundaries: route handler, use case, domain, repository, and Postgres.
A request crosses five boundaries. Dependencies point inward; the database never imports the UI.

That is enough architecture for most product work. It gives you a place to put a rule, a place to test it without a browser, and a place to swap a data source without rewriting the screen.

The layers in practice

LayerOwnsMay importTypical file
RouteHTTP, auth, parsing, status codesUse caseapp/api/invoices/route.ts
Use caseThe transaction and its orderDomain, repository contractfeatures/billing/use-case.ts
DomainRules, invariants, value typesNothingfeatures/billing/domain.ts
RepositoryQueries, ORM, external APIsDomain typesfeatures/billing/repository.ts
UIRendering, interaction, local stateUse case result typesfeatures/billing/ui/*

The table is the whole architecture. If a file does not fit one row, it probably belongs to two features, or it is infrastructure pretending to be domain logic.

A feature, end to end

Here is a billing feature with the smallest useful amount of code. The schema is shared by the route and the form; the use case is a function with an injected repository; the route is a thin adapter.

features/billing/schema.ts
import { z } from "zod";
 
export const createInvoiceSchema = z.object({
  customerId: z.string().uuid(),
  amountCents: z.number().int().positive(),
  currency: z.enum(["INR", "USD", "EUR"]).default("INR"),
});
 
export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>;
features/billing/use-case.ts
import { createInvoiceSchema, type CreateInvoiceInput } from "./schema";
import type { InvoiceRepository } from "./repository";
 
export function createInvoice(repo: InvoiceRepository) {
  return async function execute(input: CreateInvoiceInput) {
    const data = createInvoiceSchema.parse(input);
    const invoice = await repo.insert(data);
 
    // A rule that belongs to the business, not to the route.
    if (invoice.amountCents > 1_000_000) {
      await repo.flagForReview(invoice.id);
    }
 
    return invoice;
  };
}
app/api/invoices/route.ts
import { auth } from "@/features/auth";
import { createInvoice } from "@/features/billing";
import { invoiceRepository } from "@/features/billing/server";
 
export async function POST(request: Request) {
  const session = await auth();
  if (!session) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  try {
    const invoice = await createInvoice(invoiceRepository)(await request.json());
    return Response.json(invoice, { status: 201 });
  } catch (error) {
    return Response.json({ error: "Invalid invoice" }, { status: 400 });
  }
}

The route does three things: authenticate, delegate, and translate a result into HTTP. It does not know how an invoice is stored. The use case does not know that a request exists. The repository does not know what a button is.

A feature is a vertical slice: schema, use case, repository, and UI live together.
A feature is a vertical slice. Imports travel one way: UI → use case → schema → repository. If a file reaches across two features, the feature is not finished.

Where clean architecture becomes ceremony

The failure mode is real. I have seen codebases where every entity had an interface, a DTO, a mapper, a factory, and a test double, and the actual business rule was a single if statement. That is not clean. That is a bureaucracy with a dependency graph.

The smells I watch for:

  • An interface with one implementation and no test double. The interface is not buying a seam; it is buying a file.
  • A mapper that copies every field by hand. If the shapes are identical, the mapping is noise. If they are not, write down why.
  • A use case that only forwards to a repository. That is a route handler with extra steps.
  • A domain entity with no behaviour. A bag of fields is a database row. Domain logic is what makes the row interesting.
  • A dependency injection container in a Next.js app. Modules are already singletons. Import the function.

NOTE

The point of a layer is to give a kind of change a home. If a layer has never absorbed a change, it is probably not earning its rent.

The rule I actually enforce

I do not enforce the whole diagram. I enforce two rules:

  1. A feature may only be imported through its index.ts. No deep imports across feature boundaries.
  2. Inner layers may not import outer layers. The domain cannot import the repository. The use case cannot import Next.js. If a file needs next/headers, it belongs in the route or in an adapter.

Those two rules are checkable with a small ESLint boundary plugin or even a script. They survive refactors better than a folder convention because they are about direction, not names.

What changes when the feature changes

The test of a structure is not how it looks on day one. It is what happens when a product manager asks for a new rule.

With feature-first, the change usually lands in one of four places: a schema (the input changed), a use case (the rule changed), a repository (the storage changed), or the UI (the presentation changed). The diff is small and the reviewer can see which kind of change it is. That is the whole benefit. Not purity. Locality.

Next.js gives you a lot of freedom. That is useful at the start and dangerous at scale. A small amount of architecture, applied consistently, keeps the freedom from turning into a maze. The structure should be boring enough that nobody talks about it after the first week.1

Footnotes

  1. The best architecture review I have had was one sentence long: “I could find the thing I needed to change.” That is the bar. ↩