Next.js App Router Patterns
This project uses Next.js 16 App Router with route groups, a layered provider architecture, and auth middleware.
IMPORTANT: New feature pages ALWAYS go in src/app/(dashboard)/[feature]/page.tsx. Never create pages outside of the route groups.
Route Groups
The app uses route groups to separate layouts without affecting URLs:
| Route Group | Layout | Purpose | Providers |
|---|---|---|---|
(auth) | Centered card, no nav | Sign-in pages | None (inherits root) |
(dashboard) | AppShell (sidebar + topbar) | All authenticated pages | Theme, FeatureFlags, Datadog, Tooltip, Toaster |
New feature pages always go in (dashboard)/[feature]/page.tsx.
Decision Tree: Where Does This Page Go?
Is this an authentication page (sign-in, sign-up, reset password)?
├── YES → src/app/(auth)/[page]/page.tsx
│ (centered card layout, no navigation)
└── NO → Is this an authenticated app page?
├── YES → src/app/(dashboard)/[feature]/page.tsx
│ (gets AppShell + sidebar + topbar + all dashboard providers)
└── NO → Is this an API endpoint?
├── YES → src/app/api/[resource]/route.ts
└── NO → Is this a root-level page (home, 404)?
└── YES → src/app/page.tsx or src/app/not-found.tsx
Provider Architecture
Providers are layered — root providers wrap everything, dashboard providers are scoped:
Root layout (src/app/layout.tsx)
└── RootProviders (src/lib/providers/root-providers.tsx)
├── SessionProvider (NextAuth)
└── QueryClientProvider (TanStack Query)
└── (dashboard) layout
└── DashboardProviders (src/lib/providers/dashboard-providers.tsx)
├── ThemeProvider (next-themes)
├── FeatureFlagProvider (src/services/launchdarkly/)
├── DatadogProvider (src/services/datadog/)
├── TooltipProvider (Radix)
├── AppShell (Sidebar + Topbar + content)
└── Toaster (sonner)
See examples/root-providers.tsx and examples/dashboard-providers.tsx for the implementations.
Provider Rules
- •All provider logic lives in
src/lib/providers/ - •Layouts only import and compose — no provider configuration inline
- •Dashboard providers gracefully degrade when env vars are missing
- •New cross-cutting providers go in
src/lib/providers/[name]-provider.tsx - •External service providers (FeatureFlags, Datadog) live in
src/services/[name]/, notlib/providers/ - •Providers that need the session (FeatureFlags, Datadog) go in dashboard, not root
Middleware
Route protection via NextAuth middleware in middleware.ts:
- •All routes are protected by default
- •Unauthenticated users redirect to
/signin - •Authenticated users on
/signinredirect to/dashboard - •Auth API routes (
/api/auth/) pass through
See examples/middleware.ts for the implementation.
Thin Page Convention
Pages in (dashboard)/ are async Server Components that:
- •Call
await prefetchXxx()for SSR data - •Wrap children with
<Hydrate> - •Render feature components
// ✅ CORRECT — thin page
// src/app/(dashboard)/tasks/page.tsx
import { Hydrate } from "@/lib/hydrate";
import { TaskList, prefetchTasks } from "@/features/tasks";
export default async function TasksPage() {
await prefetchTasks();
return (
<Hydrate>
<TaskList />
</Hydrate>
);
}
No business logic in pages. Pages are glue between the framework and features.
CRUD Routing Convention
Entity CRUD uses Intercepting Routes + Parallel Routes:
- •Create: Modal via
@modal/(.)create/page.tsx(intercepted route) - •Create fallback: Full page
create/page.tsx(direct URL / hard refresh) - •Detail: Full page
[id]/page.tsx - •Edit: Full page
[id]/edit/page.tsx - •
@modal/default.tsxMUST rendernull - •Modal only for simple create forms. Complex forms use full pages.
- •Form components live in the feature, not in
app/.
// ❌ WRONG — page with logic, direct fetching, inline rendering
export default async function TasksPage() {
const res = await fetch("https://api.example.com/tasks");
const tasks = await res.json();
const active = tasks.filter((t: any) => t.status === "active");
return (
<div>
<h1>Tasks</h1>
{active.map((t: any) => <p key={t.id}>{t.title}</p>)}
</div>
);
}
API Route Handlers
Located in src/app/api/[resource]/route.ts:
- •Named exports per HTTP method (
GET,POST,PUT,DELETE) - •Use
ApiResponse<T>envelope from@/types/api - •Validate request bodies with Zod
.safeParse() - •Proper HTTP status codes
- •try/catch error handling
// src/app/api/tasks/route.ts
import { NextRequest } from "next/server";
import { taskSchema, createTaskInputSchema } from "@/features/tasks";
import type { ApiResponse } from "@/types/api";
import type { Task } from "@/features/tasks";
export async function GET(): Promise<Response> {
try {
const tasks = await db.tasks.findMany();
return Response.json({
data: tasks,
error: null,
meta: { total: tasks.length },
} satisfies ApiResponse<Task[]>);
} catch {
return Response.json(
{ data: null, error: { message: "Failed to fetch tasks", code: "FETCH_ERROR" } },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest): Promise<Response> {
try {
const body = await req.json();
const result = createTaskInputSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ data: null, error: { message: "Validation failed", code: "VALIDATION_ERROR" } },
{ status: 400 }
);
}
const task = await db.tasks.create({ data: result.data });
return Response.json({ data: task, error: null } satisfies ApiResponse<Task>, { status: 201 });
} catch {
return Response.json(
{ data: null, error: { message: "Failed to create task", code: "CREATE_ERROR" } },
{ status: 500 }
);
}
}
Layout Conventions
- •Root layout (
src/app/layout.tsx): Fonts +RootProviders - •Dashboard layout (
src/app/(dashboard)/layout.tsx):DashboardProviders+AppShell - •Auth layout (
src/app/(auth)/layout.tsx): Centered card layout - •Default exports only for
page.tsxandlayout.tsx
Error Handling Convention Files
The feedback feature provides state components used in Next.js convention files:
| Convention File | Component | Purpose |
|---|---|---|
error.tsx | ErrorState | Error boundary UI |
not-found.tsx | NotFoundState | 404 page UI |
loading.tsx | LoadingState | Loading state UI |
// src/app/(dashboard)/error.tsx
"use client";
import { ErrorState } from "@/features/feedback";
export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState error={error} onRetry={reset} />;
}
Available UI State Components
The feedback feature provides state components for all scenarios:
- •
ErrorState— generic error with retry action - •
NotFoundState— 404 - •
ForbiddenState— 403 - •
ServerErrorState— 500 - •
LoadingState— spinner - •
EmptyState— no data (MUST be used instead of returningnull) - •
WipState— work in progress - •
PendingState— processing
"use client" Decision Tree
Does this component use any of these? ├── React hooks (useState, useEffect, useContext, etc.) → "use client" ├── Event handlers (onClick, onChange, onSubmit, etc.) → "use client" ├── Browser APIs (window, document, localStorage, etc.) → "use client" ├── Third-party client libraries (useQuery, useRouter from next/navigation, etc.) → "use client" └── None of the above → Server Component (no directive needed)
DO NOT
- •DO NOT create pages outside of route groups — use
(auth)/or(dashboard)/. - •DO NOT put business logic in page files — pages are thin (prefetch + Hydrate + render).
- •DO NOT put provider configuration directly in layout files — use
src/lib/providers/. - •DO NOT use
default exportfor components — onlypage.tsxandlayout.tsxget default exports. - •DO NOT modify shadcn/ui files in
src/components/ui/— use them as-is. - •DO NOT add
"use client"to Server Components — only add it when the component uses hooks, event handlers, or browser APIs. - •DO NOT return
nullfor empty states — useEmptyStatefrom@/features/feedback. - •DO NOT put API logic directly in pages — create route handlers in
src/app/api/. - •DO NOT forget error handling in route handlers — always use try/catch with
ApiResponse<T>. - •DO NOT forget Zod validation in POST/PUT route handlers — always validate request bodies.