Back to Blog

React Doctor Audit: How We Fixed Every Issue in Our Next.js 16 App

We ran React Doctor on our production Next.js 16 site and scored 90/100. After fixing 8 issues in 20 minutes, we hit 96/100. Full walkthrough with before-and-after code for each fix.

We ran React Doctor on our production Next.js 16 site. Starting score: 90 out of 100, with 1 error and 70 warnings across 28 files.

Twenty minutes later: 96 out of 100, 16 warnings, 11 files. Every remaining warning is either a false positive or an intentional pattern.

Here's every issue React Doctor found and the exact code we used to fix it.

What Is React Doctor?

React Doctor is a free CLI tool that scans React projects for performance problems, accessibility violations, dead code, SEO gaps, and common anti-patterns. It works with Next.js, Vite, and plain React.

Run it like this:

bunx react-doctor@latest . --verbose

It auto-detects your framework, React version, and TypeScript setup. Scanning our 95-file project took about 600ms.

Starting Point: 90/100

✗ 1 error  ⚠ 70 warnings  across 28/95 files

Not terrible for a production site. But 70 warnings felt sloppy. Here's what we fixed.

Fix 1: Add Page-Level Metadata for SEO

What React Doctor flagged: Our homepage (src/app/page.tsx) had no metadata export. It inherited defaults from the root layout, but Google and React Doctor both prefer explicit per-page metadata.

The code:

import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Your Page Title",
  description: "A specific description for this page.",
  alternates: { canonical: "/" },
};

In Next.js App Router, every page should export its own metadata. The layout sets fallbacks. Pages should be specific. This is a direct SEO win because Google uses the page-level title and description in search results.

Fix 2: Replace Raw Script Tags with next/script

What React Doctor flagged: 13 raw <script> tags across the app, mostly for JSON-LD structured data and analytics.

Why this matters: The Next.js <Script> component controls when scripts load. Setting strategy="afterInteractive" defers execution until after hydration, which improves Time to Interactive.

Before:

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>

After:

import Script from "next/script";

<Script
  id="org-jsonld"
  type="application/ld+json"
  strategy="afterInteractive"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>

One gotcha: Next.js requires an id prop on any <Script> that uses dangerouslySetInnerHTML. Skip it and the build fails.

Fix 3: Switch to LazyMotion and Save 30KB

What React Doctor flagged: Importing motion from motion/react (the library formerly known as Framer Motion) bundles the full animation engine. That's about 30KB of JavaScript your users download whether they need it or not.

Before:

import { motion } from "motion/react";

<motion.div animate={{ opacity: 1 }}>...</motion.div>

After:

import { LazyMotion, domAnimation, m } from "motion/react";

// Wrap your app once in layout.tsx
<LazyMotion features={domAnimation}>
  {children}
</LazyMotion>

// Use m instead of motion in components
<m.div animate={{ opacity: 1 }}>...</m.div>

The feature set loads once. Every motion component in the app shares it. We had 8 animation components, so the savings were real.

Fix 4: Respect prefers-reduced-motion

What React Doctor flagged: This was the only actual error. Our app used scroll-triggered animations but did nothing for users who enable "Reduce motion" in their OS settings. That's a WCAG 2.3.3 violation.

We added two layers of protection.

Layer 1: A CSS media query in globals.css

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Layer 2: A JavaScript hook in each motion component

import { useReducedMotion } from "motion/react";

export function FadeUp({ children, className }) {
  const shouldReduceMotion = useReducedMotion();
  if (shouldReduceMotion) return <div className={className}>{children}</div>;

  return (
    <m.div
      variants={fadeUpVariants}
      initial="hidden"
      whileInView="visible"
      className={className}
    >
      {children}
    </m.div>
  );
}

The CSS catches everything globally. The JavaScript hook gives you component-level control: show content instantly instead of animating it in.

Fix 5: Stop Using Array Indices as React Keys

What React Doctor flagged: 35 instances of .map((item, i) => <div key={i}>) spread across our service pages.

Array indices as keys cause bugs when lists get reordered, filtered, or have items inserted in the middle. React uses keys to track which DOM elements changed. Index keys make that tracking unreliable.

Before:

{items.map((item, i) => (
  <li key={i}>{item}</li>
))}

After:

{items.map((item) => (
  <li key={item}>{item}</li>
))}

Our arrays contained unique strings, so the string itself works as a stable key. For arrays of objects, use a unique property like key={item.id} or key={item.slug}.

If your lists are static and never reorder, index keys won't cause visible bugs. But stable keys are a better habit and silence the warning.

Fix 6: Make the Nav Backdrop Keyboard-Accessible

What React Doctor flagged: Our mobile menu had a backdrop overlay (the dark area behind the menu) with an onClick handler on a plain <div>. No keyboard support, no ARIA role, no way for screen reader users to close the menu by clicking the backdrop.

The fix:

<div
  role="button"
  tabIndex={0}
  aria-label="Close menu"
  onClick={() => setOpen(false)}
  onKeyDown={(e) => {
    if (e.key === "Enter" || e.key === " ") setOpen(false);
  }}
/>

The rule: any clickable non-interactive element needs a role, tabIndex for keyboard focus, aria-label for screen readers, and a keyboard event handler.

Fix 7: Use next/link for Internal Navigation

What React Doctor flagged: One internal link in our ROI calculator used a raw <a> tag instead of Next.js's <Link> component.

Before:

<a href="/contact">Get a Custom Assessment</a>

After:

import Link from "next/link";
<Link href="/contact">Get a Custom Assessment</Link>

next/link gives you client-side navigation (no full page reload), automatic route prefetching, and scroll position preservation. A raw <a> tag throws all of that away and triggers a full page load.

Fix 8: Remove Dead Code

React Doctor found an unused ScaleIn component export in our motion utilities. We deleted it.

It also flagged a BlogPost type as unused, but that was a false positive. The type is used as a return type for getAllPosts(). React Doctor doesn't trace type usage through function signatures.

Final Score: 96/100

✗ 1 error  ⚠ 16 warnings  across 11/95 files

The 16 remaining warnings break down like this:

  • 13 uses of dangerouslySetInnerHTML: All for JSON-LD structured data or rendering markdown blog content as HTML. This is the standard Next.js pattern for both use cases. There's no cleaner alternative.
  • 2 large components: Our homepage is 346 lines. It's a single-page layout with no reusable logic worth extracting. Splitting it would add indirection without improving anything for users.
  • 1 reduced-motion "error": React Doctor detects the motion library in package.json but doesn't pick up our CSS media query or useReducedMotion hook. False positive.

How to Prioritize React Doctor Fixes

If you run React Doctor and get a wall of warnings, work through them in this order:

  1. Export metadata on every page: Direct SEO impact, takes 2 minutes per page
  2. Replace <script> with <Script>: Improves page load timing
  3. Switch to LazyMotion: Cuts ~30KB from your bundle with minimal code changes
  4. Handle prefers-reduced-motion: Required for WCAG accessibility compliance
  5. Replace index keys with stable keys: Prevents subtle rendering bugs
  6. Add keyboard handlers to clickable elements: Accessibility compliance
  7. Use next/link for internal navigation: Enables client-side routing
  8. Delete dead exports: Smaller bundle, cleaner codebase

We did all 8 fixes in about 20 minutes. The result: a measurably faster site, better accessibility compliance, and stronger SEO foundations.

Frequently Asked Questions

How do I install React Doctor?

You don't need to install it. Run bunx react-doctor@latest . --verbose in your project root. It downloads and runs automatically. Works with bun, npx, and pnpm dlx.

Does React Doctor work with Next.js App Router?

Yes. React Doctor auto-detects Next.js (both Pages Router and App Router) along with your React version and TypeScript configuration. It understands App Router conventions like metadata exports and server components.

What score should I aim for in React Doctor?

A score above 90 is solid for most production apps. Getting to 100 is often impractical because some warnings (like dangerouslySetInnerHTML for JSON-LD) flag patterns that are correct and intentional. Focus on fixing real issues, not chasing a perfect number.

Is dangerouslySetInnerHTML actually dangerous in Next.js?

For JSON-LD structured data where you control the input (hardcoded objects serialized with JSON.stringify), it's safe. The risk comes from injecting user-supplied HTML without sanitization. If the HTML comes from a CMS or user input, sanitize it with a library like DOMPurify first.


We build and maintain production Next.js applications at Pink Lemon8. If your React app needs a code audit or a performance overhaul, let's talk.

Have a project in mind?

Let's talk about whether custom software is the right fit for your business.

Get in Touch