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.

Comments 0

No comments yet. Be the first to comment!