The App Router gives us incredible primitives—Server Components, Server Actions, and explicit “use client” boundaries—but it doesn’t ship with a blueprint. After watching teams struggle with blurred layers and hydration chaos, I decided to publish the architecture we’ve battle-tested in production.
Repo: next-app-router-architecture
– System guide: docs/README.md
– Frontend playbook: frontend/docs/README.md
– Checklists: docs/checklists.md
– GitHub: github.com/YukiOnishi1129/next-app-router-architecture
The Product: Request & Approval System
Not a toy blog. A real app surface that stresses the App Router.
Persona | What they do |
---|---|
Requester | Draft requests, edit before submitting, track status, get notified on decisions. |
Approver | See pending approvals, take action with comments, dig into history. |
Everyone | Manage profile (name, email, password), change-email flow, reset password. |
Under the hood: Google Identity Platform + NextAuth for auth, multi-role flows, notifications, audit-friendly DTO validation. Just enough scope to reveal real App Router problems—without drowning you.
Why App Router Needs a Playbook
Common pain points we kept hitting:
- Blurred boundaries – server-only code (handlers, repositories) leaking into client components. Works in dev, blows up in prod.
- Server Action sprawl – actions scattered inside random page.tsx, entangling features with routes.
- Hydration regressions – a stray "use client" bubbles up; suddenly half the layout becomes client-rendered.
We wanted a blueprint that says: “put this here, never import that there, and here’s lint to enforce it.” So we wrote one.
Principle 1 — Layer with Intent
frontend/src/
├─ app/ # App Router: routes, layouts, metadata (thin)
├─ features/ # Domain bundles (auth, requests, approvals, settings)
├─ shared/ # Cross-cutting UI, layout chrome, providers, lib
└─ external/ # Server adapters (dto, handler, service, repository, client)
app/ stays thin
// frontend/src/app/requests/[requestId]/page.tsx
import { RequestDetailPageTemplate } from '@/features/requests/components/server/RequestDetailPageTemplate'
import type { PageProps } from '@/shared/types/next'
export default async function RequestDetailPage(
props: PageProps<'/requests/[requestId]'>
) {
const { requestId } = await props.params
const searchParams = await props.searchParams
const highlight =
Array.isArray(searchParams.highlight) ? searchParams.highlight[0] : searchParams.highlight
return (
<RequestDetailPageTemplate
requestId={requestId}
highlightCommentId={highlight}
/>
)
}
No data fetching inside the page. It forwards typed params to a feature template. That’s the rule everywhere.
features/ own orchestration
features/requests/
├─ components/
│ ├─ server/ # Server Components (page templates)
│ └─ client/ # Container / Presenter / Hook slices
├─ hooks/ # TanStack Query + client logic
├─ queries/ # Query keys + DTO helpers
├─ actions/ # Server Actions (thin wrappers)
└─ types/ # Shared types, enums
Client slices are deliberately small:
components/client/RequestList/
├─ RequestListContainer.tsx # uses hook, passes props down
├─ RequestListPresenter.tsx # pure JSX
├─ useRequestList.ts # orchestrates Query + derived state
├─ RequestList.test.tsx # co-located tests
└─ index.ts # barrel export
external/ is your escape hatch
external/
├─ dto/ # Zod schemas + TS types
├─ handler/ # Server entry points (command.server.ts / query.server.ts / *.action.ts)
├─ service/ # Domain services (business logic)
├─ repository/ # DB access (Drizzle or swap later)
└─ client/ # Outbound API clients
Because features never import repositories/services directly, swapping backends is painless: re-implement the service layer (e.g., point the repository to Go microservices instead of Drizzle) without touching React code. This pattern lets you move from “Next.js monolith” → “Next.js + Go backend” by rewiring services only.
Lint keeps us honest
Custom rules in frontend/eslint-local-rules:
- restrict-service-imports — only handlers can import external/service/**.
- restrict-action-imports — client components/hooks are the only consumers of *.action.ts.
- use-nextjs-helpers — enforce PageProps/LayoutProps, next/navigation, etc.
They run in CI; architecture violations fail the build.
Principle 2 — Routes & Layouts Drive Experience
Route groups map to auth states:
- (guest) — login, signup, email-change flows
- (authenticated) — dashboard, requests, approvals, settings
- (neutral) — password reset, terms
Layouts enforce access and render shared chrome.
// frontend/src/app/(authenticated)/layout.tsx
import { AuthenticatedLayoutWrapper } from '@/shared/components/layout/server/AuthenticatedLayoutWrapper'
import type { LayoutProps } from '@/shared/types/next'
export const dynamic = 'force-dynamic'
export default function AuthenticatedLayout(props: LayoutProps<'/'>) {
return <AuthenticatedLayoutWrapper>{props.children}</AuthenticatedLayoutWrapper>
}
AuthenticatedLayoutWrapper handles:
- requireAuthServer() check
- Query hydration via HydrationBoundary
- Rendering the sidebar + header
// frontend/src/app/(authenticated)/requests/layout.tsx
export const metadata = {
title: 'Request List | Request & Approval System',
description: 'Browse, filter, and search submitted requests.',
}
Checklist for adding a route (from docs/checklists.md):
- Pick route group; create layout.tsx + page.tsx.
- Use LayoutProps<'/path'>; export metadata from the layout.
- In the page, await props.params / props.searchParams via PageProps.
- Delegate to a feature template under components/server.
- Add loading.tsx / error.tsx if the route does real work.
- Run pnpm typegen to refresh typed routes.
Principle 3 — Server-First Data Fetching (with TanStack Query)
We lean hard on TanStack Query—server and client.
Server templates hydrate the cache
// features/requests/components/server/RequestsPageTemplate.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { RequestList } from '@/features/requests/components/client/RequestList'
import { requestKeys } from '@/features/requests/queries/keys'
import {
ensureRequestListResponse,
selectRequestListFetcher,
} from '@/features/requests/queries/requestList.helpers'
import { getQueryClient } from '@/shared/lib/query-client'
import {
listAllRequestsServer,
listAssignedRequestsServer,
listMyRequestsServer,
} from '@/external/handler/request/query.server'
import type { RequestFilterInput, RequestsStatusTabKey } from '@/features/requests/types'
export async function RequestsPageTemplate({
filters = {},
activeTabKey,
}: {
filters?: RequestFilterInput
activeTabKey: RequestsStatusTabKey
}) {
const queryClient = getQueryClient()
await queryClient.prefetchQuery({
queryKey: requestKeys.list(filters),
queryFn: async () => {
const fetcher = selectRequestListFetcher(filters, {
listMine: listMyRequestsServer,
listAssigned: listAssignedRequestsServer,
listAll: listAllRequestsServer,
})
const response = await fetcher()
return ensureRequestListResponse(response)
},
})
return (
<section className="space-y-6 px-6 py-8">
{/* header, tabs */}
<HydrationBoundary state={dehydrate(queryClient)}>
<RequestList filters={filters} />
</HydrationBoundary>
</section>
)
}
Takeaways
- Server prefetch chooses the right handler (mine/assigned/all).
- ensureRequestListResponse validates DTOs before React.
- HydrationBoundary hands a warm cache to the client.
Client hook consumes the same key
// features/requests/queries/useRequestListQuery.ts
'use client'
import { useQuery } from '@tanstack/react-query'
import { requestKeys } from '@/features/requests/queries/keys'
import {
ensureRequestListResponse,
selectRequestListFetcher,
} from '@/features/requests/queries/requestList.helpers'
import {
listAllRequestsAction,
listAssignedRequestsAction,
listMyRequestsAction,
} from '@/external/handler/request/query.action'
import type { RequestFilterInput } from '@/features/requests/types'
export const useRequestListQuery = (filters: RequestFilterInput = {}) =>
useQuery({
queryKey: requestKeys.list(filters),
queryFn: async () => {
const fetcher = selectRequestListFetcher(filters, {
listMine: listMyRequestsAction,
listAssigned: listAssignedRequestsAction,
listAll: listAllRequestsAction,
})
const response = await fetcher()
return ensureRequestListResponse(response)
},
})
The container hook stays identical on first render and refetch, thanks to server hydration. No SSR/CSR divergence.
Container ➜ Hook ➜ Presenter
// features/requests/components/client/RequestList/RequestListContainer.tsx
'use client'
import { RequestListPresenter } from './RequestListPresenter'
import { useRequestList } from './useRequestList'
import type { RequestFilterInput } from '@/features/requests/types'
export function RequestListContainer({ filters }: { filters: RequestFilterInput }) {
const { summaries, isLoading, isRefetching, errorMessage } = useRequestList({ filters })
return (
<RequestListPresenter
requests={summaries}
isLoading={isLoading}
isRefetching={isRefetching}
errorMessage={errorMessage}
/>
)
}
The container memoizes filters, calls useRequestListQuery, maps DTOs to UI-friendly summaries. Presenters render what they’re given—nothing more.
When to skip hydration
Static server views (e.g., an overview header that does a Promise.all and renders summary stats) don’t need TanStack Query. If it’s static, keep it server-only.
Mutations invalidate precisely
// features/approvals/hooks/useApproveRequest.ts
return useMutation({
mutationFn: async ({ requestId }) => {
const result = await approveRequestAction({ requestId })
if (!result.success) throw new Error(result.error ?? 'Failed to approve request')
return { requestId }
},
onSuccess: async (_data, { requestId }) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: approvalKeys.pending() }),
queryClient.invalidateQueries({ queryKey: requestKeys.detail(requestId) }),
queryClient.invalidateQueries({ queryKey: requestKeys.all }),
queryClient.invalidateQueries({ queryKey: requestKeys.history(requestId) }),
queryClient.invalidateQueries({ queryKey: notificationKeys.list() }),
])
},
})
Principle 4 — Quality Gates Codified
Custom ESLint rules (local)
frontend/eslint-local-rules houses architecture cops:
- restrict-service-imports — prevent client code from touching external/service/**.
- restrict-action-imports — ensure Server Actions are only consumed by clients/hooks.
- use-nextjs-helpers — require PageProps/LayoutProps, next/navigation, and catch missing await props.params.
- use-client-check, use-server-check — verify directive placement.
Run pnpm lint; violations fail fast.
DTO validation everywhere
// external/dto/request/ensureRequestListResponse.ts
import type { RequestListResponse, RequestListResult } from './types'
const DEFAULT_LIMIT = 20
export function ensureRequestListResponse(response: RequestListResponse): RequestListResult {
if (!response.success || !response.requests) {
throw new Error(response.error ?? 'Failed to load requests')
}
return {
requests: response.requests,
total: response.total ?? response.requests.length,
limit: response.limit ?? DEFAULT_LIMIT,
offset: response.offset ?? 0,
}
}
If the backend changes shape, you know immediately.
Error boundaries per route group
Every major section has error.tsx and loading.tsx so failures surface gracefully, not as white screens.
Tests live next to subjects
Hooks, presenters, and server templates ship with co-located tests (*.test.ts(x)) using Vitest + React Testing Library. Open, run, change, repeat.
External layer = future-proof integration
Because the UI only speaks to handlers, migrating from “Next.js + Drizzle” to “Next.js + Go microservices” is flipping the implementation inside external/service/**. Features, hooks, and components stay untouched.
Lessons Learned & Scaling
- Boundaries reduce mental load. When /app is routing-only, /features is orchestration, and /external is integration, PR reviews become trivial.
- A warm TanStack Query cache keeps UX snappy. Server-first fetching + client hydration gives you SSR speed and CSR interactivity.
- Custom lint beats tribal knowledge. Architecture decisions encoded as ESLint rules eliminate “you forgot to…” conversations.
- The external adapter pattern buys growth options. Offload heavy domains to Go? Point the service layer there; React stays the same.
When should you adopt this playbook?
- You’re shipping multi-role apps with significant server logic.
- You rely on Server Actions and RSC but need strict separation.
- You want to keep the door open for future backend refactors without touching React code.
Dive Deeper
- Repo: github.com/YukiOnishi1129/next-app-router-architecture
- System guide: docs/README.md
- Frontend playbook: frontend/docs/README.md
- Checklists: docs/checklists.md
Clone it, explore the slices, and try adding a new feature by following the checklist. Once you’ve felt the predictability, you won’t want to go back to ad-hoc App Router projects.
Appendix — .eslintrc.cjs (excerpt)
// frontend/.eslintrc.cjs
module.exports = {
root: true,
plugins: ['@typescript-eslint', 'react', 'react-hooks', 'eslint-plugin-local'],
extends: ['next/core-web-vitals', 'plugin:@typescript-eslint/recommended'],
rules: {
'local/restrict-service-imports': 'error',
'local/restrict-action-imports': 'error',
'local/use-nextjs-helpers': 'error',
'react-hooks/rules-of-hooks': 'error',
},
settings: {
'import/resolver': {
typescript: { project: './tsconfig.json' },
},
},
}
Appendix — Typed Next.js helpers
// shared/types/next.ts
import type { ReadonlyURLSearchParams } from 'next/navigation'
export type LayoutProps<_Path extends string> = {
children: React.ReactNode
}
export type PageProps<_Path extends string> = {
params: Promise<Record<string, string>>
searchParams: Promise<ReadonlyURLSearchParams | Record<string, string | string[] | undefined>>
}
Appendix — Query client helper
// shared/lib/query-client.ts
import { QueryClient } from '@tanstack/react-query'
export function getQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, refetchOnWindowFocus: false },
},
})
}
Top comments (0)