Separating Domain, Data and UI Layers in a Next.js App Router Codebase
A Pokédex case study showing how to split a Next.js App Router app into domain, data and presentation layers with Prisma, Zod, cookie auth and caching.
This article is published in English.
Sources: Clean Architecture for Frontend with Next.js (App Router): A Practical Pokédex Case Study
The Next.js App Router turns what used to be a React single-page app into a full-stack runtime: server rendering, route handlers, caching and direct database access all live in the same repository. Treat that codebase as "just components" and it degrades quickly, with pages that talk to the database, widgets that call third-party APIs and business rules hidden in JSX conditionals. This walkthrough applies Clean Architecture to an App Router project using a small Pokédex app as the running example, showing where each concern belongs and which Next.js gotchas the structure exposes.
The example application and its constraints
The Pokédex is deliberately small but touches every kind of dependency a real product has:
- Remote data: the public PokeAPI, which needs no API key.
- Local persistence: PostgreSQL accessed through Prisma.
- Stored state: each user's favorites, plus cached Pokémon details that are refreshed after seven days.
- Authentication: email and password, a session held in an httpOnly cookie, and a client-side session provider.
- Loading UX: React Suspense boundaries with skeleton components.
- Type safety: TypeScript in strict mode plus runtime validation with Zod.
- Mutations: no Server Actions; every write goes through a route handler under
/app/api/*.
What goes wrong without boundaries
Next.js lets any file reach anything, which is exactly how codebases tangle. Typical symptoms:
- page files that import the Prisma client directly;
- components that call PokeAPI themselves;
- domain rules expressed as ad hoc conditionals in the UI;
- logic you cannot test without booting the framework, because everything is wired to it.
Clean Architecture counters this: domain rules stop depending on frameworks, data sources can be swapped without UI edits, use-cases can be tested without a database or Next.js runtime, and persistence or screens can change without a rewrite.
Three layers and one dependency direction
The project is split into three layers, and dependencies only point inward toward the domain:
- Domain (the core): entities, repository interfaces and use-cases. It contains no Next.js imports, no Prisma and no concrete implementations.
- Data: repository implementations, Prisma access, the remote API client, DTOs, mappers and caching. All infrastructure is confined here.
- Presentation: pages, views, widgets, skeletons, the session provider and fetch helpers. It is purely UI and reaches data only through stable APIs or through use-cases exposed by an adapter.
A useful test: delete the data and presentation folders, and the domain should still type-check.
Keeping the App Router folder thin
A clean mental model starts with a clean directory tree. The /app directory is treated as the framework edge and holds only what Next.js needs to discover:
- routes and route groups;
layout.tsx;- route handlers under
app/api/**.
Business logic does not belong there. In the Pokédex, app/ holds the root layout, a (public) route group (login, register, Pokémon details), an (app) group (list and favorites) and the api/ handlers. Everything substantial lives under libs/domain/**, libs/data/** and libs/presentation/**, next to a prisma/ folder with the schema and migrations.
The domain layer: rules and contracts only
The domain holds three kinds of things:
- Entities such as
PokemonandUser. - Repository interfaces such as
PokemonRepo,FavoritesRepoandUserRepo. - Use-cases such as
GetPokemonDetails,ListPokemon,AddFavorite,RemoveFavorite,GetFavorites,RegisterUserandLoginUser.
Two rules keep it honest. First, nothing in the domain may import Next.js, Prisma or any runtime-specific module. Second, the domain returns domain entities, never persistence models, so a Prisma row type cannot leak into the UI through a use-case.
Use-cases depend only on the repository interfaces, not on their implementations. AddFavorite receives a FavoritesRepo through its constructor or a factory and cannot tell Postgres from an in-memory test double. That inversion is the heart of the pattern; a restricted-imports lint rule makes it enforceable.
The data layer: where infrastructure lives
Everything that touches the outside world is collected in the data layer:
- the Prisma client and schema;
- repository implementations, named with an
Implsuffix; - a remote service that wraps PokeAPI;
- a local service for the database (favorites and cache);
- DTOs and mappers that translate between wire formats and entities;
- the cache policy with its seven-day TTL.
A small dependency-injection container in libs/data/di wires implementations to interfaces, so the rest of the app asks the container for a ready-made use-case instead of constructing repositories itself.
Combining a remote and a local source
Remote data comes from PokeAPI through the remote service; locally there are two tables: Favorites, tied to authenticated users, and PokemonCache, which stores the raw details payload along with timestamps.
PokemonRepoImpl decides which source to consult, so neither use-cases nor UI know caching exists.
A seven-day cache policy
Pokémon details are cached locally so that detail pages load faster and a free public API is not hit repeatedly. The decision flow is straightforward: look up the cached record; if it exists and its timestamp is less than seven days old, map and return it; otherwise fetch from PokeAPI, validate the payload, upsert it into PokemonCache with a fresh timestamp, and return the mapped entity.
Keep the staleness check a small pure function (for example in libs/data/utils/cache.ts) so it is trivial to unit-test. If PokeAPI is down when a record is stale, consider serving the old copy instead of failing; this layer is the right place for such a policy.
Route handlers as stable contracts
Server Components could call use-cases directly, but this project routes data access through handlers under /app/api/** anyway, acting as a backend-for-frontend proxy. There are three reasons.
- A single interface serves server views, client widgets and any future external consumer.
- It keeps the system coherent, since all data access flows through the same contracts.
- It fits Clean Architecture naturally: the HTTP API is simply another edge adapter around the core.
For more on this pattern, see turning Next.js route handlers into a deliberate BFF layer.
One version-specific detail matters here. In recent Next.js releases (starting with Next.js 15), params in dynamic segments is a Promise. Route handlers and pages must await params before reading name; check the docs for your version's exact signature.
Cookie-based authentication
Authentication is exposed through three endpoints:
POST /api/auth/loginvalidates the credentials and sets an httpOnly cookie, for examplepokedex_token.GET /api/auth/sessionreads that cookie, verifies the token and returns the current session state and user.POST /api/auth/logoutclears the cookie and responds with{ ok: true }.
Why an httpOnly cookie
An httpOnly cookie is never readable from client JavaScript, so injected scripts cannot exfiltrate the JWT, and client code never stores or attaches tokens. Because the browser sends the cookie automatically, pair it with SameSite and Secure attributes and consider CSRF protection for writes.
A session provider for the UI
The UI still needs to know whether someone is logged in, who it is, and when to refresh after login or logout. A small client-side SessionProvider answers that. It loads the session from /api/auth/session, keeps status, isLoggedIn and user in React state, and exposes syncAfterLogin() and logout(). Components consume this context and never touch tokens.
Server Components, Suspense and skeletons
Where it helps, views are rendered on the server and wrapped in Suspense. That gives a faster first paint, indexable pages and an easy-to-follow data flow. A typical page combines two pieces: a Suspense boundary whose fallback is a skeleton component, and a server view that loads data and renders the finished UI.
Forwarding cookies on internal fetches
There is a subtle trap in the BFF approach. When a Server Component calls one of the app's own route handlers through an absolute URL, the server-side fetch is a brand-new outgoing request, so the user's cookies are not included automatically. The fix is to read the incoming cookie header with headers() and pass it explicitly as a Cookie header on the call to /api/favorites. This bites most often when the URL is built from the request's host and protocol.
The alternative is to skip internal HTTP entirely and have server views call use-cases directly through the container. The HTTP route keeps one contract for everything, at the cost of an extra network hop per render and a dependency on the server being reachable (which also rules it out during static builds). Direct use-case calls are faster and simpler but mean server views and client widgets use different entry points. This project keeps the stable API contracts and forwards cookies.
Favorites end to end
Favorites require an authenticated user, database reads and client-side mutations. Two parts cover this:
- FavoritesView, a Server Component that fetches
/api/favoriteswith the forwarded cookies and renders the list. - RemoveFavoriteButton, a Client Component that sends
DELETE /api/favorites/:nameand then callsrouter.refresh()so the server-rendered list is re-fetched.
router.refresh() re-renders the route's Server Components while preserving client state.
Validating every boundary with Zod
Zod checks data at each point where it crosses a boundary:
- PokeAPI responses, validated as remote DTOs before mapping;
- request bodies for the authentication endpoints;
- JSON payloads stored in the database, such as the cached details.
Parsing at the edges turns silent shape drift, such as a field renamed upstream, into an explicit early error and documents each contract in code.
A minimal but useful test baseline
You do not need a large suite to prove the architecture. Two groups of tests go a long way:
- The cache staleness helper: given a timestamp, assert whether it is older than seven days, including the boundary case.
- Use-case behavior: replace the repository interfaces with mocks and assert that each use-case calls them correctly. Keep Prisma out of domain tests entirely.
Being able to test use-cases without Next.js running is where the layering visibly pays for itself.
Getting the code running locally
The repository can be cloned over HTTPS:
git clone https://github.com/mahmoudmagdi/pokemon-clean-arch.git
cd pokemon-clean-arch
npm install
or over SSH, if you have a key configured with GitHub:
git clone git@github.com:mahmoudmagdi/pokemon-clean-arch.git
cd pokemon-clean-arch
npm install
Next, create your environment file from the provided template and fill in the database URL and secrets it lists:
cp .env.example .env
Start a PostgreSQL 16 container. The database name, user and password here are local development values only and should match what you put in .env; the named volume keeps data across container restarts.
docker run -d \
--name pokedex-postgres \
-p 5432:5432 \
-e POSTGRES_DB=pokedex_db \
-e POSTGRES_USER=pokedex_user \
-e POSTGRES_PASSWORD=pokedex_password \
-v pokedex_pg_data:/var/lib/postgresql/data \
postgres:16
Apply the Prisma migrations to create the tables:
npx prisma migrate dev
Finally, start the development server:
npm run dev
A deployed version is available as a live demo.
Wrapping up
Clean Architecture in Next.js is mostly about deciding where each kind of knowledge lives:
- Keep
/appas a thin adapter layer and move logic intolibs. - Let the domain define interfaces and entities, and let only the data layer know about Prisma, HTTP and caching.
- Validate at every boundary so contracts are enforced at runtime, not just in types.
- Choose deliberately between internal route handlers (one contract, cookie forwarding required) and direct use-case calls (faster, fewer moving parts).
- Start testing with the pure pieces, the cache policy and the use-cases.
For a weekend prototype this may be more structure than you need; for an app that will keep growing, the boundaries keep each change local.