The Route Tree: Where Router, Auth, Server Actions, and the Database Meet
You have built every piece separately: routing, server components, actions, auth, a database, tests, and a deployment pipeline. The capstone's job is to make the seams explicit — one repository where you can point at the file that owns each responsibility and explain why it lives there. Start with the tree, because in the App Router the file system is the architecture.
app/
├─ layout.tsx root layout: fonts, providers, <html>
├─ (store)/ route group — organizes URLs without changing them
│ ├─ layout.tsx storefront shell: header, nav, cart badge
│ ├─ page.tsx home
│ ├─ products/[slug]/page.tsx catalog detail
│ ├─ cart/page.tsx per-user, dynamic
│ └─ checkout/page.tsx per-user, dynamic
├─ account/orders/page.tsx session-gated
├─ admin/(dashboard)/page.tsx role-gated
├─ api/webhooks/stripe/route.ts signature-verified callback
├─ api/health/route.ts liveness probe for the load balancer
├─ error.tsx not-found.tsx loading.tsx
└─ instrumentation.ts ← no: this belongs at the project root
Four rules hold the structure together.
Everything outside app/ should have a job title too, so the tree stays readable at a glance: lib/ for the data layer and domain helpers, actions/ for server actions, components/ for shared UI (client components where they must be, and nowhere else), schemas/ for the zod definitions shared by forms and actions. Route handlers live inside their segment when they belong to it — app/api/webhooks/stripe/route.ts — not in one flat pile where nobody can tell which page owns which endpoint.
1. Data access crosses one boundary. lib/db.ts instantiates Prisma and imports 'server-only', so any accidental client import fails the build with a readable error instead of shipping a connection string to the browser.
2. Sessions are read on the server, gates run in middleware. Server components call auth() directly; middleware refreshes the session cookie and rejects unauthenticated hits to /admin, /checkout, and /account before a single line of page code runs. Client-side useEffect redirects are a UX garnish, never the security control.
3. Every mutation is a server action with the same spine:
'use server'
export async function addToCart(prev: CartState, formData: FormData) {
const session = await auth()
if (!session) return { ok: false, error: 'Sign in to continue' }
const parsed = CartSchema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { ok: false, error: 'Quantity must be 1–99' }
await limiter.check(session.userId) // abuse control from the edge topic
await db.cartItem.upsert({ /* ... */ })
revalidateTag(`cart:${session.userId}`)
return { ok: true }
}
The sequence is the same every time: browser posts the action → session resolved → zod validates → rate limiter → database write → tag revalidation → RSC re-render, with useActionState reflecting the returned state.
4. Connections are a capacity decision. A self-hosted container pool can hold direct Postgres connections; serverless replicas will exhaust max_connections the first day you get traffic, so use a pooled endpoint (PgBouncer, Prisma Accelerate) there. "The database ran out of connections" is a classic first outage, and the fix belongs in architecture, not in an incident channel.
Configuration rides along with the same discipline discussed in Logging and Error Tracking for observability: three variable sets (dev, staging, prod), Stripe keys and webhook secrets scoped per environment, and a boot-time assertion that production refuses to start with a test key.
When someone asks you to "walk me through your project", this tree is the script: router and layouts for structure, middleware and auth() for identity, server actions for writes, a server-only data layer for the database — and the next decision is which route renders how.
ISR for the Catalog, `no-store` for the Cart: A Rendering Strategy per Route
A storefront is two workloads wearing one coat. The catalog is read a thousand times per write; the cart is different for every visitor. Choosing a rendering strategy per route — rather than one strategy for the app — is the difference between a store that serves 10 rps and one that serves 10,000.
| Route | Rendering | Cache policy | Invalidated by |
|---|---|---|---|
/ |
static at build | revalidate = 3600 |
deploy, hourly timer |
/products |
ISR | revalidate = 60 |
timer, admin edits |
/products/[slug] |
ISR + generateStaticParams |
revalidate = 60 |
price/stock webhook via revalidateTag |
/cart, /checkout |
dynamic | no-store, private |
never cached — per request |
/account/* |
dynamic | no-store |
session check every request |
/api/webhooks/stripe |
dynamic POST | never cached | n/a |
// app/products/[slug]/page.tsx
export const revalidate = 60 // safety net: at most 60s stale
export const dynamicParams = true // long-tail products render on first hit
export async function generateStaticParams() {
const top = await db.product.findMany({ take: 200, orderBy: { sales: 'desc' } })
return top.map((p) => ({ slug: p.slug }))
}
Time-based revalidation alone still shows a stale price for up to a minute after an admin edit, so money-moving changes revalidate on demand:
// app/api/webhooks/stripe/route.ts
const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
if (event.type === 'checkout.session.completed') {
await db.order.create({ /* ... */ })
revalidateTag(`cart:${session.userId}`)
}
// after an admin saves a new price:
revalidateTag(`product:${id}`)
Keep both mechanisms: tags for correctness, the 60-second timer as the backstop when a webhook is missed. One deployment caveat from the infrastructure side — revalidateTag only reaches the instance that called it unless you run a shared cache handler, so multi-replica setups need Redis-backed coordination (Self-Hosting with Standalone Output) or acceptance of eventual consistency.
The cart deserves the opposite treatment, and it is a favorite interview question: "how do you guarantee one user never sees another's cart?" Three layers, all boring on purpose. The route is dynamic, so nothing is ever written to a shared cache; its responses carry Cache-Control: private, no-cache, no-store, max-age=0, so no proxy or browser may store them; and every request re-reads the session server-side. Caching a per-user page is a data-leak bug, not a performance bug — which is why the trade-off is never "a bit of staleness" but "someone else's order history".
Filters and search sit on the boundary. Reading searchParams opts the page into dynamic rendering, so either accept that cost for filtered listings or render a static shell and drive facets through client-side URL state with a suspense-wrapped results slot.
A static page can still contain live sections — that is what Suspense slots buy you: the product page streams its ISR HTML immediately while a <Suspense> boundary fills in "recently viewed" and "customers also bought" per user, each piece marked with its own cache: 'no-store'. You get static-file TTFB for the document and per-user correctness inside it, which is the same trade-off resolved at component scale instead of route scale.
Verify the intent after every change: next build prints the route table, and a cart showing ○ (static) instead of ƒ (dynamic) means something — a stray cookies() in a shared layout, a cached fetch — quietly downgraded your isolation. The same build report is where you enforce the launch gate in the next chapter.
The Pre-Launch Gate: Metadata, Error Boundaries, and a Web Vitals Budget
Features are done; now prove the store can be found, can fail safely, and can stay fast. Treat these as gates with pass/fail criteria — a checklist you can run in an afternoon, not aspirations.
Gate 1 — Search engines can parse every product.
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const p = await getProduct(params.slug)
return {
title: `${p.name} — Storefront`,
description: p.summary,
alternates: { canonical: `/products/${p.slug}` },
robots: { index: false }, // cart, checkout, account
openGraph: { images: [`/api/og?slug=${p.slug}`] },
}
}
Add app/sitemap.ts generated from the database with lastModified, app/robots.ts disallowing /cart, /account, and /api, and Product JSON-LD so results can carry price and availability:
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Product","name":"Aurora Lamp",
"offers":{"@type":"Offer","price":"49.00","priceCurrency":"USD",
"availability":"https://schema.org/InStock"}}
</script>
Pass check: curl -s /sitemap.xml returns 200 with real URLs, and the Google Rich Results test accepts a product page. Generate the social card from the same data with the colocated file convention, so every product gets an image without a design step:
// app/products/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image({ params }: { params: { slug: string } }) {
const p = await getProduct(params.slug)
return new ImageResponse(
<div style={{ fontSize: 64, background: '#111', color: '#fff', padding: 48 }}>
{p.name} · ${p.price}
</div>,
size,
)
}
Gate 2 — Every failure has a designed screen. error.tsx at the store and checkout segments gives users a retry button; global-error.tsx covers a root-layout crash and must render its own <html>; not-found.tsx paired with notFound() on an unknown slug returns a genuine 404 status rather than a soft-shown error page. Boundaries are for people — reporting is already handled by onRequestError and your error SDK, so avoid double-counting the same failure.
Gate 3 — Core Web Vitals have numbers, not vibes.
| Metric | Budget (p75) | Capstone decision that protects it |
|---|---|---|
| LCP | ≤ 2.5 s | hero image with priority + explicit dimensions, served from static/ISR HTML |
| CLS | ≤ 0.1 | every <Image> sized; recommendations rail reserves height |
| INP | ≤ 200 ms | cart updates via useActionState + transition; server components keep JS thin |
Gate 4 — The release is operable.
□ next build passes; First Load JS per route inside budget
□ env vars present in every platform environment; boot assertions green
□ smoke test: /, /products/aurora-lamp, /cart, /api/health → 200; unknown slug → 404
□ error tracker tagged with the release SHA; source maps uploaded
□ one deliberate test error appears in the dashboard before real traffic exists
□ request id present in a sampled log line
□ previous image tag retained; DB migration is backward compatible
Run it against the deployed URL, not localhost — headers, CDN behavior, and injected secrets only exist there.
This closes the roadmap deliberately: Logging and Error Tracking gave the release its nervous system, and this gate gives it a ship date. The answer you give in an interview is now one coherent sentence — "App Router storefront, ISR catalog invalidated by webhooks, dynamic cart isolated by no-store plus server-side sessions, server actions with zod and rate limits, Prisma behind a server-only module, standalone container behind a reverse proxy, tracing and error tracking tagged by release" — and every clause is a topic you can defend in depth.
Quiz
1. Which combination guarantees one buyer never sees another buyer's cached cart?
2. An admin edits a product price and you want buyers to see it immediately. Best mechanism?
3. generateStaticParams prebuilds the top 200 products and dynamicParams is true. What happens when product 201 goes live?
4. How do you make an unknown product slug return a real 404 status to search engines?
Flashcards
Question
How is each storefront route rendered, and why?
Click to reveal answer
Answer
Home and catalog are static/ISR (revalidate 60–3600) because reads vastly outnumber writes; cart, checkout, and account are dynamic with no-store because they are per-user; the Stripe webhook is a dynamic POST that is never cached. Verify the classification in the next build route table.
Question
What invalidates a product page when its price changes?
Click to reveal answer
Answer
revalidateTag(`product:${id}`) called from the admin action or the payment webhook, with export const revalidate = 60 as a safety net for missed events. Across multiple replicas, tag invalidation only reaches other instances through a shared cache handler with refreshTags().
Question
What are the three layers of cart isolation?
Click to reveal answer
Answer
Dynamic rendering so nothing is written to a shared cache; Cache-Control private, no-cache, no-store so no proxy may store the response; and a server-side session read on every request so authorization is re-evaluated per hit.
Question
What does generateStaticParams plus dynamicParams control?
Click to reveal answer
Answer
generateStaticParams prebuilds listed params at deploy time (top sellers); dynamicParams = true lets unlisted params render on first request and then be ISR-cached, while false returns notFound() for anything not prebuilt.
Question
Name the four pre-launch gates for the storefront.
Click to reveal answer
Answer
Findability (generateMetadata, canonical, sitemap.ts, robots.ts, Product JSON-LD, noindex on cart/checkout), safe failure (error.tsx per segment, global-error.tsx, not-found.tsx with a real 404), performance (p75 budgets: LCP ≤ 2.5s, CLS ≤ 0.1, INP ≤ 200ms), operability (env per environment, smoke test, release-tagged error tracker, rollback path).
Revision Notes
Key Takeaways
- 1. The App Router file system is the architecture: route groups organize URLs, middleware gates identity, and a server-only data layer keeps credentials out of the browser
- 2. Rendering strategy is chosen per route — ISR where reads dwarf writes, dynamic no-store where the response belongs to one user
- 3. Correctness of prices comes from tag-based revalidation triggered by webhooks, with the time-based window only as a backstop
- 4. Cart isolation is three layers deep (dynamic + no-store + per-request session), because caching per-user data is a leak, not a staleness problem
- 5. Shipping is gated by measurable criteria: metadata parses, 404s are real, Web Vitals stay inside p75 budgets, and the release is observable and reversible
Interview Tips
- • "Walk me through your project" — one sentence per layer: router/layouts, middleware + auth, server actions with zod and rate limits, server-only Prisma, ISR catalog invalidated by webhooks, dynamic cart, standalone container behind a proxy, tracing tagged by release
- • "How do you keep the catalog fast and the cart correct?" — ISR with on-demand revalidateTag for reads, dynamic private no-store plus server-side sessions for per-user data; be ready to explain what breaks if the two are swapped
- • "What did you check before launch?" — walk the gates: route table sizes, env per environment, smoke test on the real URL, error boundary and true 404 behavior, sitemap/JSON-LD validation, Web Vitals p75, rollback path
- • "Where is the weakest part of your system?" — name the multi-instance story (shared cache handler, action encryption key, connection pooling) and the mitigation already in place; owning a trade-off reads better than claiming perfection
Cheat Sheet
Capstone: Production Storefront
Route tree:
app/
├─ (store)/ home, products/[slug] (ISR), cart, checkout (dynamic)
├─ account/orders session-gated
├─ admin/(dashboard) role-gated
├─ api/webhooks/stripe signature-verified, dynamic
├─ api/health load-balancer probe
├─ error.tsx not-found.tsx loading.tsx
lib/db.ts import 'server-only'
middleware.ts session refresh + gates
instrumentation.ts root: tracing, onRequestError, boot assertions
Rendering matrix:
| Route | Strategy | Invalidation |
|---|---|---|
/, /products |
static / ISR | deploy or timer |
/products/[slug] |
ISR + generateStaticParams |
revalidateTag('product:id') + 60s timer |
/cart, /checkout, /account |
dynamic, no-store |
never cached |
/api/webhooks/stripe |
dynamic POST | n/a |
Action spine: session → zod validation → rate limit → DB write → revalidateTag → RSC re-render via useActionState.
Launch gates: metadata + sitemap + JSON-LD · error boundaries with real 404s · p75 budgets (LCP 2.5 s / CLS 0.1 / INP 200 ms) · smoke test on the deployed URL · release-tagged tracker with source maps · rollback retained.