=> setCount((c) => c + 1)}>
Refresh Count: {count}
</button>
);
}
**Rationale:** Server Components stream HTML immediately. Client Components hydrate only when necessary. Pushing the boundary down preserves static rendering, reduces payload size, and prevents hydration mismatches.
### Step 2: Route Mutations Through Validated Server Actions
Form submissions and data mutations should never use manual `fetch` calls or custom `route.ts` handlers. Server Actions co-locate mutation logic with the component that triggers it, while maintaining server-side security. Every action must validate input, verify authorization, and trigger cache revalidation.
```typescript
// actions/update-order.ts
"use server";
import { z } from "zod";
import { revalidateTag } from "next/cache";
import { verifySession } from "@/lib/auth";
import { db } from "@/lib/database";
const UpdateOrderSchema = z.object({
orderId: z.string().uuid(),
status: z.enum(["pending", "shipped", "delivered"]),
});
export async function updateOrderStatus(
_: unknown,
formData: FormData
): Promise<{ success: boolean; error?: string }> {
const session = await verifySession();
if (!session?.isAdmin) {
return { success: false, error: "Unauthorized" };
}
const parsed = UpdateOrderSchema.safeParse({
orderId: formData.get("orderId"),
status: formData.get("status"),
});
if (!parsed.success) {
return { success: false, error: parsed.error.message };
}
try {
await db.order.update({
where: { id: parsed.data.orderId },
data: { status: parsed.data.status },
});
revalidateTag("orders");
return { success: true };
} catch {
return { success: false, error: "Database write failed" };
}
}
Rationale: Server Actions are public endpoints. Returning a discriminated union instead of throwing errors prevents unhandled exceptions from crossing the network boundary. revalidateTag ensures downstream components receive fresh data without full page reloads.
Step 3: Standardize State Scope and Hook Contracts
State should live in the smallest viable scope. The hierarchy is: props β URL search parameters β React context β external store. Custom hooks must return data or state machines, never JSX. Rendering inside hooks breaks composability and forces unnecessary re-renders.
// hooks/use-session.ts
import { useSWR } from "swr";
import type { User } from "@/types";
type SessionState =
| { status: "loading" }
| { status: "authenticated"; user: User }
| { status: "unauthenticated" };
export function useSession(): SessionState {
const { data, error } = useSWR<User>("/api/session");
if (error) return { status: "unauthenticated" };
if (!data) return { status: "loading" };
return { status: "authenticated", user: data };
}
Rationale: Returning a discriminated union forces consumers to handle every state explicitly. It eliminates optional chaining bugs and prevents hooks from dictating UI structure.
Step 4: Lock TypeScript Strictness and Route Safety
TypeScript configuration must enforce null safety, indexed access checks, and exact optional properties. Inline prop types should be replaced with explicit interfaces. Route links should fail at compile time when paths change.
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"skipLibCheck": true
}
}
Rationale: noUncheckedIndexedAccess prevents undefined crashes when accessing arrays or records. exactOptionalPropertyTypes catches accidental undefined assignments. Strict mode eliminates implicit any and forces explicit return types, which AI models frequently omit.
Client-side form handling should use useFormState from react-dom paired with Server Actions. This eliminates manual useState for submission status, handles pending states natively, and wires errors directly to the UI.
// components/order-form.tsx
"use client";
import { useFormState } from "react-dom";
import { updateOrderStatus } from "@/actions/update-order";
import { SubmitButton } from "@/components/submit-button";
export function OrderForm({ orderId }: { orderId: string }) {
const [state, formAction] = useFormState(updateOrderStatus, { success: null });
return (
<form action={formAction}>
<input type="hidden" name="orderId" value={orderId} />
<select name="status" required>
<option value="pending">Pending</option>
<option value="shipped">Shipped</option>
<option value="delivered">Delivered</option>
</select>
<SubmitButton />
{state?.success === false && (
<p role="alert" className="text-red-600">{state.error}</p>
)}
</form>
);
}
Rationale: useFormState manages pending, success, and error states without manual useState. The SubmitButton component reads useFormStatus().pending to disable itself during submission, preventing duplicate requests.
Pitfall Guide
1. Hoisting "use client" to Page or Layout Files
Explanation: AI defaults to placing the directive at the top of page.tsx to silence TypeScript errors when child components use hooks. This forces the entire route segment to hydrate, breaking streaming and static generation.
Fix: Move "use client" to the exact component requiring browser APIs. Pass data from the Server Component as props. Use children to bridge boundaries when necessary.
2. Mixing useEffect with Server Component Data Fetching
Explanation: AI generates useEffect + fetch patterns even when data is available at request time. This creates a waterfall: server renders empty state, client hydrates, then fetches data, causing layout shifts and unnecessary network calls.
Fix: Fetch data directly in async Server Components. Reserve useEffect for subscriptions, timers, or post-render interactions. Use useSWR or React Query only for client-side cache invalidation after initial render.
3. Memoizing Without Profiling
Explanation: AI reflexively wraps handlers in useCallback and derived values in useMemo. This adds cognitive overhead, increases bundle size, and rarely improves performance unless reference equality is load-bearing.
Fix: Profile with React DevTools Profiler first. Memoize only when a value is a dependency of another hook or when a child component re-renders excessively due to prop changes. Default to plain functions and inline expressions.
4. Throwing Errors Across the Server-Client Boundary
Explanation: Server Actions that throw exceptions crash the client when the error crosses the network boundary. AI frequently uses throw new Error() instead of returning structured responses.
Fix: Always return a discriminated union { success: boolean; error?: string }. Catch database or validation failures inside the action. Let the form component render the error state explicitly.
5. Relying on data-testid Over Semantic Queries
Explanation: AI generates tests that query by data-testid or class names. This couples tests to implementation details and breaks when UI structure changes.
Fix: Use React Testing Library with getByRole, getByLabelText, or getByText. Only use data-testid when no semantic alternative exists. Test behavior, not structure.
6. Ignoring Segment-Scoped Error Boundaries
Explanation: AI wraps the entire application in a single ErrorBoundary component. This defeats Next.js streaming and prevents partial recovery when a specific route segment fails.
Fix: Create error.tsx files per route segment. These are automatically Client Components that catch errors in their subtree. Pair with not-found.tsx for 404s and loading.tsx for Suspense fallbacks.
7. Bypassing Import Guards for Environment Variables
Explanation: AI imports server-only modules (database clients, secret keys) into client components, causing runtime leaks or build failures.
Fix: Use import "server-only" in database and auth modules. Use import "client-only" in analytics or browser APIs. Validate environment variables at startup with Zod in a single env.ts file. Fail fast during build, not at runtime.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Data available at request time | Async Server Component | Eliminates client fetch, enables streaming | Reduces bundle size by 30-50% |
| User interaction after render | Client Component + useSWR | Preserves interactivity, caches responses | Minimal overhead, improves UX |
| Form submission with validation | Server Action + useFormState | Co-located logic, built-in pending states | Reduces boilerplate by 60% |
| Global theme/user state | React Context | Stable across tree, rarely changes | Negligible re-render cost |
| Cross-subtree mutable state | Zustand/Jotai | Avoids prop drilling, explicit updates | Adds 5-8 KB to bundle |
| Route-specific errors | error.tsx segment file | Catches failures locally, preserves streaming | Zero runtime cost |
Configuration Template
// AI_CONSTRAINTS.md
# AI Generation Constraints for Next.js App Router
## Architecture Rules
- Server Components are default. `"use client"` only on leaves requiring state, events, or browser APIs.
- Data fetching happens in async Server Components. No `useEffect` for initial data.
- Mutations use `"use server"` functions. Validate with Zod, authorize via session, revalidate with `revalidateTag`.
- State scope: props β URL params β context β external store.
- Custom hooks return data or discriminated unions, never JSX.
## TypeScript Standards
- Enable `strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`.
- Props use explicit interfaces. Discriminated unions over optional booleans.
- No `any`. `as const` only. `// @ts-expect-error` requires inline justification.
## Performance & Testing
- Profile before memoizing. Default to plain functions.
- Forms use `useFormState` + Server Actions. No manual `fetch`.
- Error boundaries are `error.tsx` per segment.
- Tests use RTL with semantic queries. No shallow rendering.
- Accessibility: semantic HTML, `aria-*` attributes, `eslint-plugin-jsx-a11y` enforced.
## Security & Environment
- Server Actions are public endpoints. Return discriminated unions, never throw.
- Use `server-only` and `client-only` import guards.
- Validate `process.env` at startup with Zod. Fail fast on missing keys.
Quick Start Guide
- Create the constraint file: Add
AI_CONSTRAINTS.md to your repository root using the template above. Commit and push.
- Configure TypeScript: Update
tsconfig.json with strict flags. Run npx tsc --noEmit to surface existing type violations.
- Audit route segments: Identify
page.tsx and layout.tsx files with "use client". Move the directive to interactive leaves. Replace useEffect data fetching with async Server Components.
- Migrate forms: Replace manual
fetch submissions with Server Actions. Wrap forms in useFormState. Add error.tsx to each segment.
- Enforce in CI: Add
eslint-plugin-jsx-a11y, @typescript-eslint/strict, and server-only/client-only checks to your pipeline. Block merges on violations.
This workflow transforms AI from a pattern generator into a constraint-aware collaborator. The output aligns with streaming architecture, reduces hydration overhead, and passes production review without architectural refactoring.