The Missing Next.js AGENTS.md File
January 25, 2026
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.
// 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 <DashboardClient data={data} user={session.user} />;
}
// BAD: Client component fetches in useEffect
"use client";
export default function DashboardPage() {
const [data, setData] = useState(null);
useEffect(() => { fetchData().then(setData); }, []);
if (!data) return <Loading />; // Flash of loading, unauthorized content risk
return <Dashboard data={data} />;
}
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:
// 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:
// 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:
// lib/api/client.ts
import { hc } from "hono/client";
import type { AppType } from "./server";
export const api = hc<AppType>("/");
Every call is fully typed—no Zod on the client:
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.
URL State Over Component State
State that should survive refresh or be shareable belongs in the URL—tabs, filters, form steps, modals, search queries:
// 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():
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
import { useQueryState, parseAsInteger } from "nuqs";
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));
Parsers for integers, booleans, arrays, JSON. nuqs.47ng.com
iron-session — Encrypted Session Cookies
No database needed. httpOnly, encrypted cookies:
import { getIronSession } from "iron-session";
export async function getSession() {
return getIronSession<SessionData>(await cookies(), {
password: process.env.SESSION_SECRET!,
cookieName: "__session",
});
}
gql.tada — Type-safe GraphQL
Types inferred from schema at build time. No codegen watchers:
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:
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:
import { ok, err, Result } from "better-result";
async function safeFetch<T>(url: string): Promise<Result<T, Error>> {
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<T>(schema: z.ZodSchema<T>, data: unknown): Result<T, z.ZodError> {
const result = schema.safeParse(data);
return result.success ? ok(result.data) : err(result.error);
}
// Usage
const result = await safeFetch<User>("/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:
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:
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",
},
},
});
<button className={button({ intent: "secondary" })}>Click</button>
intentui — Accessible Components
React Aria-based, Tailwind-styled. Accessible by default:
import { Button, TextField, Select } from "intentui";
<TextField label="Email" type="email" isRequired />
<Select label="Country">
<Select.Item id="us">United States</Select.Item>
</Select>
oxlint + oxfmt — Fast Linting & Formatting
ESLint and Prettier are slow. Oxc tools are 50-100x faster:
{
"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.