DEV Community

Olivia Craft
Olivia Craft

Posted on

CLAUDE.md for React and Next.js: 13 Rules That Make AI Write Production-Ready Components

The default Claude Code output for a React/Next.js task is a tutorial. Class-component muscle memory, useEffect everywhere, "use client" slapped on a layout.tsx, a 250-line component that fetches in three places and renders four error states. None of it is wrong in isolation. All of it is wrong for an App Router app shipped in 2026.

The fix isn't a longer prompt. It's a CLAUDE.md at the repo root — the file Claude reads on every turn — encoding the patterns that make generated code look like your codebase instead of a Stack Overflow answer from 2021.

Here are 13 rules that survive review. Each one started as a recurring failure mode in real PRs.

1. Server Components by default — "use client" only at the leaf

Every component under app/ is a Server Component until proven otherwise. The directive moves to the smallest leaf that actually needs useState, useEffect, an event handler, or a browser API. Never on layout.tsx or page.tsx.

// ❌ AI's instinct: hoist the directive to silence the error
"use client";
export default function Page() {
  return <article><Counter /></article>;
}

// ✅ Server Component renders a Client leaf
// app/page.tsx
export default async function Page() {
  const post = await db.post.findFirst();
  return <article>{post?.title}<Counter /></article>;
}

// app/_components/counter.tsx
"use client";
export function Counter() { /* useState here */ }
Enter fullscreen mode Exit fullscreen mode

A Client Component cannot render a Server Component as a child — only as a children prop. Push the boundary down so streaming, static rendering, and bundle size stay healthy.

2. Async data fetching lives in Server Components, not useEffect

If the data exists at request time, fetch it in an async Server Component. No useEffect + setState waterfall, no SWR, no React Query on the server.

// ❌ The pattern AI defaults to
"use client";
export default function Posts() {
  const [posts, setPosts] = useState([]);
  useEffect(() => { fetch("/api/posts").then(r => r.json()).then(setPosts); }, []);
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

// ✅ The async-component pattern
export default async function Posts() {
  const posts = await db.post.findMany({ take: 20 });
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
Enter fullscreen mode Exit fullscreen mode

Client-side fetching is reserved for data that depends on user interaction after the initial render. Everything else is a network round-trip you didn't need.

3. Mutations go through Server Actions — validate, authorize, revalidate

Every mutation is a "use server" function. No bespoke route.ts for form submissions. Validate with Zod, authorize against the session, revalidate the affected paths or tags.

"use server";
import { z } from "zod";

const Schema = z.object({ title: z.string().min(1).max(120) });

export async function createPost(_: unknown, formData: FormData) {
  const session = await auth();
  if (!session) return { ok: false, error: "unauthorized" } as const;

  const parsed = Schema.safeParse({ title: formData.get("title") });
  if (!parsed.success) return { ok: false, error: parsed.error.flatten() } as const;

  const post = await db.post.create({ data: { ...parsed.data, userId: session.userId } });
  revalidateTag("posts");
  return { ok: true, data: post } as const;
}
Enter fullscreen mode Exit fullscreen mode

Server Actions are public endpoints. Treat them like API routes that just happen to be co-located with the component that calls them. Never throw to the client — return a discriminated union the form can render.

4. Hooks discipline — exhaustive deps, custom hooks as nouns

Three rules that compound:

  1. exhaustive-deps is on, no exceptions. A // eslint-disable-next-line over a useEffect is a bug 95% of the time.
  2. No conditional hooks, no hooks in loops. If you find yourself reaching for one, the component needs splitting.
  3. Custom hooks return data, not JSX. A useUser() returns { user, status }, not a <Spinner />. Composability dies the moment a hook owns rendering.
// ❌ Hook with a baked-in render path
function useUser() {
  const { data } = useSWR("/api/me");
  if (!data) return <Spinner />; // never do this
  return data;
}

// ✅ Hook returns a typed state machine
function useUser(): { status: "loading" } | { status: "ready"; user: User } {
  const { data } = useSWR<User>("/api/me");
  return data ? { status: "ready", user: data } : { status: "loading" };
}
Enter fullscreen mode Exit fullscreen mode

5. State lives in the smallest scope that works — props, then URL, then context, then store

Order matters and AI gets it backward. The default reaches for Zustand or Redux on day one. The actual hierarchy:

  1. Props — the data is owned by a parent two levels up. Pass it.
  2. URL search params — filters, sort, pagination, modal-open state. Survives reload, shareable, free.
  3. React context — theme, current user, feature flags. Stable across the tree, rarely changes.
  4. External store (Zustand/Jotai) — only when multiple unrelated subtrees write to the same value.

Redux Toolkit isn't wrong; it's a sledgehammer for problems that are usually screws. Don't let AI install it because the component hierarchy looks intimidating.

6. TypeScript: strict, noUncheckedIndexedAccess, no any

// tsconfig.json (the parts AI loves to drop)
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Component props are interfaces, not inline types. Discriminated unions over optional booleans ({ status: "idle" } | { status: "error"; message: string } beats { isError?: boolean; errorMessage?: string }). Enable experimental.typedRoutes in next.config.ts so <Link href="/dashboard/foo"> fails at compile time when the route is gone.

The only acceptable cast is as const. as Foo is a TODO; // @ts-expect-error needs an inline reason and a linked issue.

7. useMemo, useCallback, memo — profile, then add

The single most common waste in AI-generated React is reflexive memoization. useCallback around every handler, useMemo around every derived value, React.memo on every leaf.

// ❌ Performance theater
const handleClick = useCallback(() => setOpen(true), []);
const items = useMemo(() => data.map(d => ({ ...d, label: d.name })), [data]);
return <Button onClick={handleClick}>...</Button>;
Enter fullscreen mode Exit fullscreen mode

The rule: only memoize when (a) a profiler shows it matters, or (b) the value is a dependency of another hook and reference equality is load-bearing. Otherwise the readability cost compounds and the bundle pays for the imports.

React.memo is for leaves that re-render on every parent commit and render expensive trees. Most components are neither.

8. Forms — useFormState + Server Actions, no manual fetch

"use client";
import { useFormState } from "react-dom";
import { createPost } from "./actions";

export function NewPostForm() {
  const [state, formAction] = useFormState(createPost, { ok: null });
  return (
    <form action={formAction}>
      <input name="title" required />
      <SubmitButton />
      {state?.ok === false && <p role="alert">{JSON.stringify(state.error)}</p>}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

SubmitButton reads useFormStatus().pending to disable itself — never track submitting state in useState. Errors come from the action's discriminated return; never from a thrown exception across the network boundary.

9. Error boundaries are files, not hooks — segment-scoped

App Router gives you error.tsx per route segment. Use it. The pattern AI generates — wrapping the whole app in a single ErrorBoundary — defeats the streaming model.

// app/dashboard/error.tsx
"use client";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <section role="alert">
      <h2>Something broke in the dashboard.</h2>
      <button onClick={reset}>Try again</button>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

error.tsx is always a Client Component. It catches errors thrown in Server Components rendered under that segment and in Client Components below it. Pair with not-found.tsx for 404s and loading.tsx for the Suspense fallback.

10. Accessibility is a build error, not a Lighthouse afterthought

  • Semantic HTML first: <button> for actions, <a> (via next/link) for navigation. Never <div onClick>.
  • Every interactive element has an accessible name — visible label, aria-label, or aria-labelledby.
  • Forms use <label htmlFor>. Errors are wired with aria-describedby and aria-invalid.
  • Focus management on route change is automatic with next/link; for modals, trap focus and restore it on close.
  • Run eslint-plugin-jsx-a11y with recommended and let it block CI.
// ❌ The classic AI miss
<div className="btn" onClick={onSubmit}>Save</div>

// ✅
<button type="button" onClick={onSubmit}>Save</button>
Enter fullscreen mode Exit fullscreen mode

11. Testing: React Testing Library, queries by role, no shallow

The test file imports the real component, mounts it with render, and queries by accessible role or label — never by class name, never by data-testid unless there's no semantic option.

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

test("clicking save calls the action", async () => {
  const onSave = jest.fn();
  render(<NewPostForm onSave={onSave} />);
  await userEvent.type(screen.getByLabelText(/title/i), "Hello");
  await userEvent.click(screen.getByRole("button", { name: /save/i }));
  expect(onSave).toHaveBeenCalledWith({ title: "Hello" });
});
Enter fullscreen mode Exit fullscreen mode

No enzyme, no shallow rendering. If a test needs to know about implementation details (component names, internal state), the component is doing too much — split it before mocking it.

For Server Components and Server Actions, write integration tests against a real test DB. Vitest + @testing-library/react + a Next.js test runner like next/experimental/testing or Playwright for end-to-end.

12. server-only, client-only, and a validated env.ts

Three import-graph guards that turn runtime leaks into build errors:

// lib/db.ts
import "server-only";
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient();

// lib/analytics.ts
import "client-only";
export function track(event: string) { window.analytics?.track(event); }

// env.ts — single source of truth for process.env
import { z } from "zod";
const Env = z.object({
  DATABASE_URL: z.string().url(),
  NEXTAUTH_SECRET: z.string().min(32),
  NEXT_PUBLIC_SITE_URL: z.string().url(),
});
export const env = Env.parse(process.env);
Enter fullscreen mode Exit fullscreen mode

Server-only secrets must NOT be prefixed NEXT_PUBLIC_ — that prefix inlines them into the client bundle. Add a CI grep:

! git grep -nE 'NEXT_PUBLIC_.*(SECRET|KEY|TOKEN|PASSWORD)' -- '*.ts' '*.tsx'
Enter fullscreen mode Exit fullscreen mode

13. next/image, next/font, next/link — never the bare HTML

The Next.js wrappers exist because the bare elements fail in production: layout shift from <img>, FOUT from <link rel="font">, full-page reloads from <a href> for internal routes.

// ❌
<img src="/hero.jpg" />
<a href="/dashboard">Dashboard</a>

// ✅
import Image from "next/image";
import Link from "next/link";

<Image src="/hero.jpg" alt="" width={1200} height={630} priority />
<Link href="/dashboard">Dashboard</Link>
Enter fullscreen mode Exit fullscreen mode

Configure images.remotePatterns (never the deprecated images.domains) for every external host. Load fonts with next/font/google or next/font/local so the bytes are self-hosted, subset, and shipped without a render-blocking <link>.

What Claude gets wrong in React and Next.js without these rules

  • Adds "use client" to layout.tsx because a deep child needs state — kills streaming for the whole subtree.
  • Wires useEffect + fetch inside a component that could be an async Server Component.
  • Reaches for Redux Toolkit on a three-route app where URL params would do.
  • Memoizes every callback and derived value before measuring anything.
  • Uses <div onClick> instead of <button> because it copied the styling from a tutorial.
  • Builds a route.ts for a form submission instead of a Server Action.
  • Imports next/head from Pages Router muscle memory — does nothing in App Router.

A CLAUDE.md with these 13 rules at the repo root means the next AI-generated PR looks like the codebase, not like a tutorial. Diff stays small, review stays short, and the bundle stays under budget.


If this saved you a code review, the full pack covers Go, TypeScript, Python, Java, Kotlin, Rust, Scala, Flutter, Rails, C++, and more. Drop-in CLAUDE.md files, no fluff.

Get the full pack: https://oliviacraftlat.gumroad.com/l/skdgt
Free starter sample: https://oliviacraftlat.gumroad.com/l/pomoo

Top comments (0)