React Compiler with Next.js: Automatic Optimization Guide

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Prerequisites

Before starting this tutorial, make sure you have:

  • Node.js 20+ installed
  • Next.js 15+ project (App Router recommended)
  • Basic understanding of React hooks and memoization (useMemo, useCallback, React.memo)
  • A code editor like VS Code

What Is React Compiler?

React Compiler (formerly known as React Forget) is a build-time tool that automatically optimizes your React components by inserting memoization where needed. Instead of manually wrapping values in useMemo, callbacks in useCallback, and components in React.memo, the compiler analyzes your code and does it for you.

This means:

  • No more manual memoization — the compiler handles it
  • Smaller bundle sizes — unnecessary re-renders are eliminated at build time
  • Better performance by default — every component is optimized without developer effort
  • Cleaner code — remove boilerplate useMemo/useCallback wrappers

React Compiler understands the Rules of React and safely transforms your code while preserving correctness.

What You'll Build

In this tutorial, you will:

  1. Set up React Compiler in a Next.js 15 project
  2. Configure the Babel plugin and ESLint integration
  3. Understand what the compiler optimizes and how
  4. Debug compiled output to verify optimizations
  5. Measure real-world performance improvements
  6. Handle edge cases and opt-out scenarios

Step 1: Create a Next.js Project

If you already have a Next.js 15+ project, skip to Step 2. Otherwise, create a new one:

npx create-next-app@latest react-compiler-demo --typescript --tailwind --app --src-dir
cd react-compiler-demo

Verify your React version is 19 or later:

npm ls react

You should see react@19.x.x. React Compiler requires React 19+.

Step 2: Install React Compiler

Install the compiler Babel plugin and ESLint plugin:

npm install -D babel-plugin-react-compiler eslint-plugin-react-compiler

These two packages are all you need:

  • babel-plugin-react-compiler — the core compiler that transforms your code at build time
  • eslint-plugin-react-compiler — surfaces violations of the Rules of React that would prevent safe compilation

Step 3: Configure Next.js

Next.js 15 has built-in support for React Compiler. Enable it in your next.config.ts:

import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  experimental: {
    reactCompiler: true,
  },
};
 
export default nextConfig;

That single line is enough to enable the compiler for your entire project. Next.js handles the Babel plugin integration automatically.

Advanced Configuration

For more control, you can pass options:

const nextConfig: NextConfig = {
  experimental: {
    reactCompiler: {
      compilationMode: "annotation", // Only compile opted-in components
      panicThreshold: "CRITICAL_ERRORS", // Fail build on critical issues
    },
  },
};

The compilationMode options are:

ModeDescription
"infer"Default. Compiles all components and hooks automatically
"annotation"Only compiles components/hooks with "use memo" directive
"all"Compiles everything, even if it violates Rules of React (use with caution)

For most projects, the default "infer" mode is the right choice.

Step 4: Set Up ESLint Integration

Add the ESLint plugin to catch Rules of React violations early. Update your .eslintrc.json:

{
  "plugins": ["react-compiler"],
  "rules": {
    "react-compiler/react-compiler": "error"
  }
}

Or if you use the flat ESLint config (eslint.config.mjs):

import reactCompiler from "eslint-plugin-react-compiler";
 
export default [
  {
    plugins: {
      "react-compiler": reactCompiler,
    },
    rules: {
      "react-compiler/react-compiler": "error",
    },
  },
];

Now run ESLint to check your codebase:

npx next lint

Any violations will appear as errors, telling you exactly what to fix before the compiler can safely optimize your code.

Step 5: Understanding What Gets Optimized

Let's look at a practical example. Consider this component before React Compiler:

// components/ProductList.tsx — before optimization
"use client";
 
import { useState } from "react";
 
interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}
 
function ProductCard({ product }: { product: Product }) {
  console.log(`Rendering ProductCard: ${product.name}`);
  return (
    <div className="border rounded-lg p-4">
      <h3 className="font-bold">{product.name}</h3>
      <p className="text-gray-600">${product.price}</p>
    </div>
  );
}
 
function CategoryFilter({
  categories,
  selected,
  onSelect,
}: {
  categories: string[];
  selected: string;
  onSelect: (cat: string) => void;
}) {
  console.log("Rendering CategoryFilter");
  return (
    <div className="flex gap-2 mb-4">
      {categories.map((cat) => (
        <button
          key={cat}
          onClick={() => onSelect(cat)}
          className={selected === cat ? "bg-blue-500 text-white px-3 py-1 rounded" : "bg-gray-200 px-3 py-1 rounded"}
        >
          {cat}
        </button>
      ))}
    </div>
  );
}
 
export default function ProductList({ products }: { products: Product[] }) {
  const [selectedCategory, setSelectedCategory] = useState("All");
  const [searchQuery, setSearchQuery] = useState("");
 
  const categories = ["All", ...new Set(products.map((p) => p.category))];
 
  const filteredProducts = products.filter((p) => {
    const matchesCategory = selectedCategory === "All" || p.category === selectedCategory;
    const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase());
    return matchesCategory && matchesSearch;
  });
 
  return (
    <div>
      <input
        type="text"
        value={searchQuery}
        onChange={(e) => setSearchQuery(e.target.value)}
        placeholder="Search products..."
        className="border rounded px-3 py-2 mb-4 w-full"
      />
      <CategoryFilter
        categories={categories}
        selected={selectedCategory}
        onSelect={setSelectedCategory}
      />
      <div className="grid grid-cols-3 gap-4">
        {filteredProducts.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

The Problem Without React Compiler

Without the compiler, every keystroke in the search input causes:

  1. ProductList re-renders (state changed)
  2. categories array is recreated (new reference every render)
  3. CategoryFilter re-renders (new categories prop reference)
  4. onSelect callback is recreated (new function reference)
  5. All ProductCard components re-render

The Old Manual Fix

Before React Compiler, you would manually add:

// Manually memoizing everything — tedious and error-prone
const categories = useMemo(
  () => ["All", ...new Set(products.map((p) => p.category))],
  [products]
);
 
const filteredProducts = useMemo(
  () => products.filter((p) => { /* ... */ }),
  [products, selectedCategory, searchQuery]
);
 
const handleSelect = useCallback(
  (cat: string) => setSelectedCategory(cat),
  []
);
 
// Plus wrapping child components in React.memo()
const ProductCard = React.memo(function ProductCard({ product }: { product: Product }) {
  // ...
});

What React Compiler Does Automatically

With React Compiler enabled, the original clean code (no useMemo/useCallback) is automatically transformed at build time. The compiler:

  1. Memoizes the categories computation
  2. Memoizes the filteredProducts filtering
  3. Memoizes the onSelect callback
  4. Caches JSX elements that haven't changed
  5. Skips re-rendering CategoryFilter when only searchQuery changes

You write clean code. The compiler handles performance.

Step 6: Verifying Compiler Output

To confirm the compiler is working, install React DevTools and look for the "Memo" badge:

# In your browser, install React DevTools extension
# Then open DevTools → Components tab

Components optimized by React Compiler show a "Memo ✨" badge in React DevTools. This confirms the compiler has automatically memoized that component.

You can also check the compiled output directly. Add this to your next.config.ts for debugging:

const nextConfig: NextConfig = {
  experimental: {
    reactCompiler: {
      // Log compilation results
      logger: {
        logEvent(filename, event) {
          console.log(`[React Compiler] ${filename}:`, event);
        },
      },
    },
  },
};

Run your dev server and check the terminal output:

npm run dev

You'll see logs showing which components were compiled and what optimizations were applied.

Step 7: Measuring Performance

Let's measure the actual impact. Create a performance test component:

// app/perf-test/page.tsx
"use client";
 
import { useState, useEffect, useRef } from "react";
 
function ExpensiveChild({ value }: { value: number }) {
  // Simulate expensive render
  const start = performance.now();
  while (performance.now() - start < 2) {
    // Busy wait for 2ms
  }
  return <div className="p-2 border rounded">{value}</div>;
}
 
export default function PerfTest() {
  const [count, setCount] = useState(0);
  const [unrelated, setUnrelated] = useState(0);
  const renderCount = useRef(0);
  const startTime = useRef(0);
 
  useEffect(() => {
    renderCount.current++;
  });
 
  const items = Array.from({ length: 100 }, (_, i) => i);
 
  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold mb-4">Performance Test</h1>
      <div className="flex gap-4 mb-4">
        <button
          onClick={() => {
            startTime.current = performance.now();
            setCount((c) => c + 1);
          }}
          className="bg-blue-500 text-white px-4 py-2 rounded"
        >
          Increment Related: {count}
        </button>
        <button
          onClick={() => {
            startTime.current = performance.now();
            setUnrelated((u) => u + 1);
          }}
          className="bg-gray-500 text-white px-4 py-2 rounded"
        >
          Increment Unrelated: {unrelated}
        </button>
      </div>
      <p>Render count: {renderCount.current}</p>
      <div className="grid grid-cols-10 gap-2">
        {items.map((i) => (
          <ExpensiveChild key={i} value={i + count} />
        ))}
      </div>
    </div>
  );
}

Without React Compiler: Clicking "Increment Unrelated" re-renders all 100 ExpensiveChild components (about 200ms delay).

With React Compiler: Clicking "Increment Unrelated" skips all ExpensiveChild re-renders because their props haven't changed (near-instant response).

Use Chrome DevTools Performance tab to record and compare:

  1. Open DevTools, go to Performance tab
  2. Click Record
  3. Click "Increment Unrelated" 5 times
  4. Stop recording
  5. Compare flame charts with and without the compiler

Step 8: Handling Edge Cases

Opting Out of Compilation

If a component doesn't work correctly after compilation, you can opt it out:

// Add "use no memo" directive to skip compilation
"use no memo";
 
export default function LegacyComponent() {
  // This component won't be compiled
  // Useful for components that rely on referential identity
}

Opting In (Annotation Mode)

If you're using compilationMode: "annotation", opt in specific components:

// Add "use memo" directive to enable compilation
"use memo";
 
export default function OptimizedComponent() {
  // Only this component gets compiled
}

Common Violations

The compiler will skip components that violate the Rules of React. Common issues:

1. Mutating props or state directly:

// BAD — compiler cannot optimize this
function BadComponent({ items }: { items: string[] }) {
  items.sort(); // Mutating props!
  return <ul>{items.map((item) => <li key={item}>{item}</li>)}</ul>;
}
 
// GOOD — create a new array
function GoodComponent({ items }: { items: string[] }) {
  const sorted = [...items].sort();
  return <ul>{sorted.map((item) => <li key={item}>{item}</li>)}</ul>;
}

2. Calling hooks conditionally:

// BAD — hooks must be called unconditionally
function BadComponent({ show }: { show: boolean }) {
  if (show) {
    const [value, setValue] = useState(0); // Conditional hook!
  }
}
 
// GOOD — always call hooks at top level
function GoodComponent({ show }: { show: boolean }) {
  const [value, setValue] = useState(0);
  if (!show) return null;
  return <div>{value}</div>;
}

3. Using refs during render:

// BAD — reading refs during render breaks memoization
function BadComponent() {
  const ref = useRef(0);
  ref.current++; // Side effect during render!
  return <div>{ref.current}</div>;
}
 
// GOOD — use refs in effects or event handlers
function GoodComponent() {
  const ref = useRef(0);
  useEffect(() => {
    ref.current++;
  });
  return <div>Check console</div>;
}

Step 9: Migrating an Existing Project

For existing projects, follow this gradual migration strategy:

Phase 1: Add ESLint Plugin First

npm install -D eslint-plugin-react-compiler

Run the linter and fix all violations before enabling the compiler. This ensures your code follows the Rules of React.

Phase 2: Enable in Annotation Mode

// next.config.ts
const nextConfig: NextConfig = {
  experimental: {
    reactCompiler: {
      compilationMode: "annotation",
    },
  },
};

Add "use memo" to a few components and test thoroughly.

Phase 3: Switch to Infer Mode

Once you're confident, switch to the default mode:

const nextConfig: NextConfig = {
  experimental: {
    reactCompiler: true,
  },
};

Phase 4: Remove Manual Memoization

Now you can safely remove manual useMemo, useCallback, and React.memo calls:

# Find all manual memoization in your codebase
grep -rn "useMemo\|useCallback\|React.memo" --include="*.tsx" --include="*.ts" src/

Remove them one by one, verifying the compiler handles each case correctly.

Step 10: Best Practices

Do

  • Write clean, idiomatic React — the compiler rewards simple code
  • Follow the Rules of React — pure components, no side effects during render
  • Use the ESLint plugin — catch violations before they reach production
  • Test performance with DevTools — verify the compiler is helping

Don't

  • Don't keep manual memoization alongside the compiler — it's redundant
  • Don't use "use no memo" as a first resort — fix the underlying issue instead
  • Don't rely on referential identity for business logic — the compiler may change when objects are created
  • Don't mutate objects or arrays in place — always create new references

Server Components

React Compiler primarily benefits Client Components ("use client"). Server Components already render once on the server, so memoization has less impact. Focus your optimization efforts on interactive client-side code.

Troubleshooting

Build Errors After Enabling Compiler

If your build fails, check the error message. Common causes:

  1. Syntax not supported — ensure you're using standard React patterns
  2. Third-party library conflict — some libraries use non-standard patterns; use "use no memo" on wrapper components
  3. Outdated React version — upgrade to React 19+

Component Behaves Differently

If a component works differently after compilation:

  1. Add "use no memo" to isolate the issue
  2. Check if the component relies on referential identity
  3. Verify it follows the Rules of React
  4. Report the issue to the React Compiler GitHub repository

No Performance Improvement

If you don't see improvements:

  1. Your components may already be efficient
  2. The bottleneck might be network or data fetching, not rendering
  3. Use React DevTools Profiler to identify the actual bottleneck

Conclusion

React Compiler transforms how we write React applications. Instead of littering code with useMemo, useCallback, and React.memo, you write clean, simple components and let the compiler handle optimization automatically.

Key takeaways:

  • One line in next.config.ts enables automatic optimization
  • ESLint plugin catches issues before they reach production
  • Clean code wins — the compiler rewards idiomatic React
  • Gradual adoption is supported through annotation mode
  • Performance gains are real — especially for complex UIs with frequent re-renders

The future of React performance is automatic. Start using React Compiler today and let your code be both clean and fast.

Next Steps


Want to read more tutorials? Check out our latest tutorial on Complete Guide to shadcn/ui with Next.js: Building Modern Interfaces.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Build a Real-Time Full-Stack App with Convex and Next.js 15

Learn how to build a real-time full-stack application using Convex and Next.js 15. This tutorial covers schema design, queries, mutations, real-time subscriptions, authentication, and file uploads — all with end-to-end type safety.

30 min read·