# Next.js 15 Patterns Standard

> **Scope:** nextjs-neon
> **Layer:** 2 (on keyword)
> **Keywords:** nextjs, react, app router, server component, client component
> **Load When:** nextjs keywords detected

**Verified against:** Next.js 15 + TanStack Query 5 + Zod 4 + react-hook-form 7. Last-verified: 2026-05-20.

---

Stack: Next.js 15 + Neon PostgreSQL + .NET Backend

## Core Rules

- ALWAYS use App Router (not Pages Router)
- Default to Server Components -- add `'use client'` only when needed
- ALWAYS colocate loading.tsx and error.tsx with page.tsx
- NEVER call the database directly from client -- use Route Handlers as BFF
- ALWAYS validate inputs with Zod on both client and server
- Use TypeScript strict mode (`"strict": true`)

## Server vs Client Components

| Aspect | Server Component (default) | Client Component (`'use client'`) |
|--------|---------------------------|-----------------------------------|
| Renders | Server only | Server SSR + Client hydration |
| Access to | DB, env vars, fs, async/await | Browser APIs, useState, useEffect, events |
| Bundle | Not included | Included in JS bundle |
| Use when | Data fetching, static content | Interactivity, forms, real-time |

Decision: Need useState/useEffect/onClick/browser APIs? Client Component. Otherwise Server Component.

## File-Based Routing

```
app/
  layout.tsx              # Root layout
  page.tsx                # / (home)
  loading.tsx / error.tsx # Loading UI / Error boundary
  not-found.tsx           # 404
  dashboard/
    layout.tsx            # Nested layout
    page.tsx              # /dashboard
    loading.tsx
  api/documents/
    route.ts              # GET/POST /api/documents
    [id]/route.ts         # GET/PUT/DELETE /api/documents/:id
```

## Layout and Error Boundaries

```tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return <html lang="en"><body><Providers>{children}</Providers></body></html>;
}

// loading.tsx
export default function Loading() { return <div className="animate-pulse">Loading...</div>; }

// error.tsx — MUST be 'use client'
'use client';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return <div><h2>Something went wrong</h2><button onClick={reset}>Try again</button></div>;
}
```

## Route Handlers (BFF Pattern)

```ts
// app/api/documents/route.ts — BFF pattern (delegates to .NET API)
import { auth } from "@/lib/auth/server";
import { NextResponse } from "next/server";
import { z } from "zod";

export const dynamic = "force-dynamic";

const API_URL = process.env.API_URL!; // .NET backend

const CreateSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
});

export async function GET() {
  const { data: session } = await auth.getSession();
  if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const res = await fetch(`${API_URL}/api/documents`, {
    headers: { Authorization: `Bearer ${session.session.access_token}` },
  });
  return NextResponse.json(await res.json());
}

export async function POST(request: Request) {
  const { data: session } = await auth.getSession();
  if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const parsed = CreateSchema.safeParse(await request.json());
  if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });

  const res = await fetch(`${API_URL}/api/documents`, {
    method: "POST",
    headers: { Authorization: `Bearer ${session.session.access_token}`, "Content-Type": "application/json" },
    body: JSON.stringify(parsed.data),
  });
  return NextResponse.json(await res.json(), { status: res.status });
}
```

> **Rule:** Route handlers act as BFF (Backend-For-Frontend) — they delegate to the .NET API. Database access lives in the .NET backend via EF Core, never in Next.js route handlers.

## React Query + Neon

```tsx
// providers/query-provider.tsx
'use client';
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";

export function QueryProvider({ children }: { children: React.ReactNode }) {
  const [client] = useState(() => new QueryClient({
    defaultOptions: { queries: { staleTime: 60_000, retry: 1 } },
  }));
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
```

```tsx
// hooks/use-documents.ts
export function useDocuments() {
  return useQuery({
    queryKey: ["documents"],
    queryFn: async () => {
      const res = await fetch("/api/documents");
      if (!res.ok) throw new Error("Failed to fetch");
      return res.json();
    },
  });
}

export function useCreateDocument() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (data: { title: string; content: string }) => {
      const res = await fetch("/api/documents", {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });
      if (!res.ok) throw new Error("Failed to create");
      return res.json();
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ["documents"] }),
  });
}
```

## Form Handling (react-hook-form + Zod)

```tsx
'use client';
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const schema = z.object({
  title: z.string().min(1, "Required").max(200),
  content: z.string().min(1, "Required"),
});

export function DocumentForm() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<z.infer<typeof schema>>({
    resolver: zodResolver(schema),
  });
  const create = useCreateDocument();
  return (
    <form onSubmit={handleSubmit((data) => create.mutateAsync(data))}>
      <input {...register("title")} />
      {errors.title && <span>{errors.title.message}</span>}
      <textarea {...register("content")} />
      {errors.content && <span>{errors.content.message}</span>}
      <button type="submit" disabled={isSubmitting}>Save</button>
    </form>
  );
}
```

## shadcn/ui

Install: `npx shadcn@latest init` then `npx shadcn@latest add button input card dialog form`.
Components are copied to `components/ui/` -- NOT an npm dependency, your code to customize.

## TypeScript Strict Patterns

| Pattern | Approach |
|---------|----------|
| API responses | Zod schema + `z.infer<typeof schema>` |
| Props | Explicit interface, no `any` |
| Event handlers | `React.ChangeEvent<HTMLInputElement>` |
| Null safety | `?.` over type assertions, `if (!data) return null` |

## Common Mistakes

| Wrong | Right | Why |
|-------|-------|-----|
| `'use client'` on every component | Default to Server Components | Unnecessary JS bundle size |
| Direct DB from client | Route Handler `/api/*` as BFF | Exposes queries, harder to secure |
| `any` for API responses | Zod schema + infer | No runtime safety |
| Missing loading.tsx | Colocate with page.tsx | Blank page during load |
| `useEffect` for data fetching | React Query `useQuery` | No caching, race conditions |
| Form validation on submit only | Zod resolver + react-hook-form | Delayed error feedback |
| shadcn as npm package | `npx shadcn@latest add` | Copy-paste system, not a dependency |

## TypeScript Strict Mode (Required)

Always enable `"strict": true` in `tsconfig.json`. This is non-negotiable for
agent-assisted development because agents rely on compiler errors to self-correct.
Without strict mode, type/null errors only surface at runtime where agents cannot observe them.

```json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true
  }
}
```

Strict mode enables: `strictNullChecks`, `noImplicitAny`, `strictFunctionTypes`, `strictPropertyInitialization`.
