- Framework: Next.js App Router only (
app/), nopages/ - Language: TypeScript everywhere, no JS
- Runtime: only Node
- Package manager: pnpm, enforced via CI
├── app/ # routes, layouts, metadata
├── components/ # shared UI (framework-agnostic)
├── features/<feature>/ # feature modules (product code lives here)
├── lib/ # shared utilities (no React)
├── server/ # server-only (db, repositories, auth)
├── styles/ # global styles
├── types/ # shared types (avoid if type lives in feature)
└── pub # static assets
Rules:
- All product logic in
features/*unless truly global - No "utils" dumping ground: feature-scoped or
lib/with clear ownership
- Route segments for areas:
app/(marketing)/...,app/(app)/... - Dynamic routes:
[id]only, avoid deep nesting - Route handlers:
app/api/.../route.ts - Redirects:
redirect()in server components,useRouter()in client - Internal links:
<Link>only, never<a>
Default: Everything is Server Component unless it needs state, effects, browser APIs, or event handlers.
Rules:
"use client"at top of file, must be rare- Client components cannot import from
server/*(enforce with ESLint) - Data fetching in server components or server actions, not
useEffect
- Prefer server-side fetching in route-level components, pass data down
- Use
fetch()with explicit caching:
// Default: no caching
fetch(url, { cache: 'no-store' })
// With caching: document why
fetch(url, { next: { revalidate: 60, tags: ['invoices'] } })- Tag invalidation for revalidation
- No ad-hoc caching layers in components
- Use for mutations (forms, button actions)
- Placement:
features/<feature>/actions.tsorapp/<route>/actions.ts - Naming:
verbNounAction
// features/invoices/actions.ts
'use server'
export async function createInvoiceAction(data: CreateInvoiceInput) {
const parsed = createInvoiceSchema.safeParse(data)
if (!parsed.success) {
return { error: 'VALIDATION_ERROR' }
}
// ...
return { data: invoice }
}Rules:
- Validate input (Zod)
- Return typed results
- Never expose internal errors, map to friendly codes
Use when needed for:
- Third-party webhooks
- Non-React clients
- Streaming endpoints
Response shape:
// Always JSON with { data, error }
return Response.json({ data: result })
return Response.json({ error: 'NOT_FOUND' }, { status: 404 })- Use proper status codes, never "200 with error"
Priority:
- URL state + server state (default)
- Local component state
- Context (truly global UI only)
- Library (pick one: Zustand/Jotai/Redux, ban others)
- Remote server state via React Query or avoided entirely
- Auth centralized:
server/auth/* - Authorization rules in feature server layer, not UI
- Never trust client state for permissions
- All server actions and APIs enforce authorization
server/
├── db/ # client, migrations
├── repositories/ # queries (small, typed, composable)
└── services/ # domain operations, transactions
- No raw DB calls in React components
- Transactions managed in service layer
Single typed module: server/env.ts
// server/env.ts
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string(),
NEXT_PUBLIC_APP_URL: z.string().optional(),
})
export const env = envSchema.parse(process.env)Naming:
- Server-only:
FOO_BAR - Client-exposed:
NEXT_PUBLIC_FOO_BAR(only when strictly necessary) - No direct
process.env.Xoutside env module