# The Missing Next.js AGENTS.md File 2026-01-25 Every Next.js project needs an opinionated guide for AI coding assistants. Here's mine. AI assistants are good at code. They're bad at *your* code conventions. Without explicit guidance, Claude/Cursor/Copilot will use Server Actions when you prefer API routes, reach for `useEffect` when RSC would be cleaner, put auth checks in middleware when they belong in page components, and create loading states for data that should be fetched server-side. The fix? A `CLAUDE.md` (or `AGENTS.md`, `CURSOR.md`, whatever) that captures your team's opinions. --- ## 1. Architecture ### Server Components First Pages are Server Components. They fetch data and validate sessions server-side. Client components receive props, not promises. ```tsx // GOOD: Server Component fetches and validates export default async function DashboardPage() { const session = await requireSession(); // Redirects if invalid const data = await fetchUserData(session.uid); return ; } // BAD: Client component fetches in useEffect "use client"; export default function DashboardPage() { const [data, setData] = useState(null); useEffect(() => { fetchData().then(setData); }, []); if (!data) return ; // Flash of loading, unauthorized content risk return ; } ``` No loading states for auth. No flash of unauthorized content. The client component receives validated, typed props. ### What Goes Where **Server Components (`page.tsx`):** - Data fetching - Session validation - Redirects - Database queries - API calls with secrets **Client Components (`"use client"`):** - Interactive forms - Event handlers - Browser APIs - Real-time updates (tanstack-query) ### useEffect is for Side Effects, Not Data Legitimate useEffect: - Event listeners (resize, click outside) - Third-party library setup - Timers/intervals - DOM measurements Not legitimate: - Fetching data → use RSC or tanstack-query - Auth checks → use RSC with redirect - Derived state → just compute it ### No Server Actions — Use Hono RPC Server Actions are elegant in demos. In practice, FormData is a nightmare to type, error handling is implicit, and debugging is harder than explicit fetch calls. Instead, a single catch-all route with Hono: ```ts // app/api/[...path]/route.ts import app from "@/lib/api/server"; import { handle } from "hono/vercel"; export const GET = handle(app); export const POST = handle(app); export const DELETE = handle(app); ``` Define routes with Zod validation: ```ts // lib/api/server.ts import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; const app = new Hono().basePath("/api"); const routes = app .post("/auth/session", zValidator("json", SessionSchema), async (c) => { const { idToken } = c.req.valid("json"); return c.json({ success: true }); }) .get("/user/profile", async (c) => { const user = await getUser(); return c.json(user); }); export type AppType = typeof routes; export default app; ``` Typed client: ```ts // lib/api/client.ts import { hc } from "hono/client"; import type { AppType } from "./server"; export const api = hc("/"); ``` Every call is fully typed—no Zod on the client: ```tsx const res = await api.api.auth.session.$post({ json: { idToken } }); const data = await res.json(); // typed! ``` Mini-tRPC without the tRPC complexity. See [Hono RPC docs](https://hono.dev/docs/guides/rpc). ### URL State Over Component State State that should survive refresh or be shareable belongs in the URL—tabs, filters, form steps, modals, search queries: ```tsx // GOOD: Step in URL const [step, setStep] = useQueryState("step", parseAsInteger.withDefault(1)); // BAD: Lost on refresh const [step, setStep] = useState(1); ``` ### Refreshing Server Component Data When a client mutation changes data rendered by a parent RSC, call `router.refresh()`: ```tsx function ProfileForm({ member }: { member: Member }) { const router = useRouter(); const mutation = useMutation({ mutationFn: updateMember, onSuccess: () => router.refresh(), // Re-runs RSC, gets fresh data }); } ``` --- ## 2. Libraries ### nuqs — Type-safe URL State ```tsx import { useQueryState, parseAsInteger } from "nuqs"; const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1)); ``` Parsers for integers, booleans, arrays, JSON. [nuqs.47ng.com](https://nuqs.47ng.com/) ### iron-session — Encrypted Session Cookies No database needed. httpOnly, encrypted cookies: ```tsx import { getIronSession } from "iron-session"; export async function getSession() { return getIronSession(await cookies(), { password: process.env.SESSION_SECRET!, cookieName: "__session", }); } ``` ### gql.tada — Type-safe GraphQL Types inferred from schema at build time. No codegen watchers: ```ts export const ARTICLES_QUERY = graphql(` query Articles($limit: Int) { articles(pagination: { limit: $limit }) { documentId title slug } } `); const { articles } = await client(ARTICLES_QUERY, { limit: 10 }); // fully typed ``` Run `pnpm codegen` after schema changes. ### @t3-oss/env-nextjs — Validated Environment Variables Fail fast on missing env vars: ```ts import { createEnv } from "@t3-oss/env-nextjs"; export const env = createEnv({ server: { DATABASE_URL: z.string().url(), API_SECRET: z.string().min(32), }, client: { NEXT_PUBLIC_API_URL: z.string().url(), }, runtimeEnv: { /* ... */ }, }); ``` ### better-result — Railway-Oriented Error Handling No try/catch spaghetti: ```ts import { ok, err, Result } from "better-result"; async function safeFetch(url: string): Promise> { try { const res = await fetch(url); if (!res.ok) return err(new Error(`HTTP ${res.status}`)); return ok(await res.json()); } catch (e) { return err(e instanceof Error ? e : new Error(String(e))); } } function safeZodParse(schema: z.ZodSchema, data: unknown): Result { const result = schema.safeParse(data); return result.success ? ok(result.data) : err(result.error); } // Usage const result = await safeFetch("/api/user"); result.match({ ok: (user) => console.log(user.name), err: (error) => console.error(error.message), }); ``` ### next-intl — i18n and Formatting Even for single-language sites, useful for date/number formatting: ```tsx import { useFormatter } from "next-intl"; const format = useFormatter(); format.dateTime(date, { dateStyle: "long" }); format.number(amount, { style: "currency", currency: "USD" }); ``` ### Tailwind + tailwind-variants — Styling CSS-in-JS is dead. `tv()` for variant logic: ```tsx import { tv } from "tailwind-variants"; const button = tv({ base: "rounded-lg font-medium transition-colors", variants: { intent: { primary: "bg-primary text-white hover:bg-primary/90", secondary: "bg-gray-100 hover:bg-gray-200", }, }, }); ``` ### intentui — Accessible Components React Aria-based, Tailwind-styled. Accessible by default: ```tsx import { Button, TextField, Select } from "intentui"; ``` ### oxlint + oxfmt — Fast Linting & Formatting ESLint and Prettier are slow. Oxc tools are 50-100x faster: ```json { "scripts": { "lint": "oxlint --tsconfig tsconfig.json", "format": "oxfmt .", "check": "pnpm lint && pnpm format --check && tsc --noEmit" } } ``` Type-aware linting catches real bugs. Format on save. Never think about it again. --- ## Write the File These opinions aren't universal truths. Server Actions are fine. Middleware auth works. The point is *having* documented opinions that AI assistants can follow. Your `AGENTS.md` is a contract between your team and your AI tools. Without it, every AI-generated PR is a coin flip on whether it matches your conventions. Write the file.