# 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.