Prerequisites
Before starting, make sure you have:
- Node.js 20 or later installed
- A Next.js 15+ project using the App Router
- Basic familiarity with React hooks and component rendering
What You Will Build
By the end of this tutorial you will have:
- React Scan integrated into your Next.js development environment
- A clear mental model of how to read the visual overlay
- Fixed at least one real performance regression — with React Compiler or manual memoization
- A reusable
onRendermonitor you can drop into any project
What Is React Scan?
React Scan is a zero-config performance diagnostic tool for React applications. It wraps the React reconciler and highlights every component that re-renders on screen — no Babel plugin, no profiler session, no guesswork.
Each highlight tells you:
- Orange outline — the component rendered this cycle
- Grey outline — the component rendered but produced no DOM change (unnecessary render)
- Render count badge — how many times a component has rendered since the page loaded
Unlike the React DevTools Profiler, React Scan is always-on during development. You see re-renders as they happen, not after clicking Stop on a recording.
Step 1: Install React Scan
Add the package as a development dependency:
npm install react-scan --save-dev
# or
pnpm add -D react-scanStep 2: Create a ReactScanProvider Component
In Next.js App Router, the root layout is a Server Component — you cannot call scan() at module level there. Instead, use the useScan hook inside a dedicated Client Component.
Create components/ReactScanProvider.tsx:
"use client";
import { useScan } from "react-scan";
export function ReactScanProvider() {
useScan({
enabled: process.env.NODE_ENV === "development",
log: false,
showToolbar: true,
animationSpeed: "fast",
trackUnnecessaryRenders: true,
showFPS: true,
showNotificationCount: true,
});
return null;
}useScan initializes the scanner on the client only, so it is safe in an SSR context and will never affect your server-rendered HTML.
Step 3: Mount the Provider in the Root Layout
Open app/layout.tsx and add ReactScanProvider as the first child of <body>:
import { ReactScanProvider } from "@/components/ReactScanProvider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ReactScanProvider />
{children}
</body>
</html>
);
}Start the dev server with npm run dev and open your browser. You should see a floating toolbar in the lower-right corner and orange flashes over components as they re-render.
Step 4: Reading the Visual Overlay
Orange highlights
An orange highlight appears every time a component's render function runs. This is normal and expected — the goal is not to eliminate all re-renders, only the unnecessary ones.
Grey highlights
A grey highlight means the component rendered but produced output identical to the previous render. React Scan calls these unnecessary renders. They consume CPU time without updating the UI, making them the primary target for optimization.
The floating toolbar
The toolbar shows:
- FPS meter — frames per second; anything below 30 indicates a janky user experience
- Notification badge — count of unnecessary renders caught since the page loaded
Step 5: Reproduce a Common Anti-Pattern
Let us build a realistic example. Create app/dashboard/page.tsx:
import { UserCard } from "@/components/UserCard";
import { StatsPanel } from "@/components/StatsPanel";
export default function DashboardPage() {
return (
<main>
<UserCard user={{ name: "Alice", role: "admin" }} />
<StatsPanel config={{ refreshInterval: 5000 }} />
</main>
);
}Create components/UserCard.tsx:
"use client";
import { useState } from "react";
type User = { name: string; role: string };
export function UserCard({ user }: { user: User }) {
const [count, setCount] = useState(0);
return (
<div>
<p>{user.name} — {user.role}</p>
<button onClick={() => setCount((c) => c + 1)}>
Clicked {count} times
</button>
</div>
);
}And components/StatsPanel.tsx:
"use client";
type Config = { refreshInterval: number };
export function StatsPanel({ config }: { config: Config }) {
return <div>Refreshes every {config.refreshInterval}ms</div>;
}Now click the button repeatedly. React Scan highlights UserCard orange on every click — expected, because local state changed. But StatsPanel flashes grey: it re-renders on every parent re-render even though the values inside config never change. The culprit is the inline object literal { refreshInterval: 5000 } — JavaScript creates a brand-new object reference on every render, so React considers the prop "changed".
Step 6: Fix Unnecessary Renders
Option A — useMemo (manual)
Stabilize the reference in DashboardPage:
"use client";
import { useMemo } from "react";
import { UserCard } from "@/components/UserCard";
import { StatsPanel } from "@/components/StatsPanel";
export default function DashboardPage() {
const statsConfig = useMemo(() => ({ refreshInterval: 5000 }), []);
return (
<main>
<UserCard user={{ name: "Alice", role: "admin" }} />
<StatsPanel config={statsConfig} />
</main>
);
}Reload the page. The grey highlight on StatsPanel is gone.
Option B — React Compiler (automatic, recommended)
If your project uses React Compiler 1.0 (stable since October 2025, battle-tested at Meta), you do not need useMemo at all. The compiler detects stable values and memoizes them automatically at build time.
Enable it in next.config.ts for Next.js 16:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
};
export default nextConfig;For Next.js 15 with the standalone Vite plugin:
npm install babel-plugin-react-compiler@latest// vite.config.ts
import { reactCompilerPreset } from "@rolldown/plugin-babel";
export default {
plugins: [reactCompilerPreset()],
};Rebuild and re-open the browser. The grey highlights on StatsPanel disappear — without touching your component code.
Step 7: Programmatic Monitoring with onRender
For automated performance budgets, use the onRender callback to log or assert on render counts:
"use client";
import { useScan } from "react-scan";
import type { Fiber, Render } from "react-scan";
export function ReactScanProvider() {
useScan({
enabled: process.env.NODE_ENV === "development",
log: false,
showToolbar: true,
trackUnnecessaryRenders: true,
onRender: (fiber: Fiber, renders: Array<Render>) => {
const componentName =
(fiber.type as { displayName?: string; name?: string })
?.displayName ??
(fiber.type as { name?: string })?.name ??
"Anonymous";
if (renders.length > 5) {
console.warn(
`[react-scan] ${componentName} rendered ${renders.length} times in one commit`
);
}
},
});
return null;
}This logs a warning whenever a component renders more than 5 times in a single commit cycle — a useful signal during code review.
Step 8: Query-Param Activation for Staging
You may want to share a debugging URL with a teammate without enabling React Scan for everyone on staging. Update ReactScanProvider to respect a ?scan=true query parameter:
"use client";
import { useScan } from "react-scan";
function isScanEnabled() {
if (process.env.NODE_ENV === "development") return true;
if (typeof window === "undefined") return false;
return new URLSearchParams(window.location.search).get("scan") === "true";
}
export function ReactScanProvider() {
useScan({
enabled: isScanEnabled(),
showToolbar: true,
trackUnnecessaryRenders: true,
});
return null;
}Append ?scan=true to any URL in your staging environment to activate the overlay on demand.
Step 9: CDN Script Tag Alternative
If you prefer not to add an npm dependency, load React Scan via a Next.js Script component:
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{process.env.NODE_ENV === "development" && (
<Script
src="https://unpkg.com/react-scan/dist/auto.global.js"
strategy="beforeInteractive"
/>
)}
{children}
</body>
</html>
);
}The auto.global.js build activates automatically with no configuration. This is ideal for quick investigations but the npm package gives you full programmatic control via useScan and onRender.
Troubleshooting
The toolbar does not appear
Verify that your ReactScanProvider is a "use client" component and is rendered before your page content inside <body>.
All components flash orange on every keystroke
This is expected for controlled input fields. Focus on grey highlights first — those are the actionable unnecessary renders.
React Scan slows down the dev server
Set animationSpeed: "off" to disable the highlight animations while keeping the programmatic onRender callback active. This reduces visual overhead significantly.
useScan is undefined at runtime
Confirm you are importing from "react-scan" (the npm package) and not from the CDN script. The hook API is only exported from the npm package.
Next Steps
- Pair React Scan with the React DevTools Profiler flame chart for deep analysis of expensive renders.
- Wire the
onRendercallback into a CI assertion to fail a build when a critical component's render count exceeds a threshold. - Read the companion guide on React Compiler automatic memoization to see how the compiler eliminates the renders React Scan surfaces.
- Use React Scan alongside TanStack Query to spot server-state subscriptions that trigger more re-renders than expected.
Conclusion
React Scan gives you an always-on visual overlay that turns React re-renders from invisible events into glowing, countable highlights. By following this tutorial you can install it in any Next.js App Router project, interpret the orange and grey signals correctly, fix unnecessary renders either manually with useMemo or automatically with React Compiler, and set up a programmatic onRender monitor to catch regressions before they reach production.