
Content creator and developer at UICraft Marketplace, sharing insights and tutorials on modern web development.
Save hours of development time with our premium Next.js templates. Built with Next.js 16, React 19, and Tailwind CSS 4.
Get the latest articles, tutorials, and product updates delivered to your inbox.
There's a special kind of frustration that comes with production-only errors. You've built your Next.js application, tested it thoroughly in development, and everything works perfectly. Then you deploy, and suddenly users are seeing cryptic error pages with messages like "Application error: a client-side exception has occurred."
If you've encountered Minified React error #310 ("Rendered more hooks than during the previous render") specifically when using the notFound() function inside a suspended server component, you're not alone. This is a documented issue that has puzzled developers since Next.js 14, and it continues to catch teams off guard because it only manifests in production builds.
In this article, we'll dissect exactly why this happens, trace it to the root cause involving loading.tsx and next/link, and provide you with concrete solutions to fix and prevent this error from crashing your production site.
The scenario typically unfolds like this: you have a dynamic route that fetches data and calls notFound() when the resource doesn't exist. In development mode, everything works flawlessly. Your not-found page renders correctly, users see the appropriate 404 message, and you ship to production confident in your implementation.
Then the reports start coming in. Users are experiencing crashes. Your error monitoring shows Minified React error #310 being thrown, but you can't reproduce it locally.
Here's a typical setup that triggers this issue:
// app/[locale]/product/[id]/page.tsx
import
The error message you'll see in production is:
Minified React error #310; visit https://reactjs.org/docs/error-decoder.html?invariant=310
When decoded, this translates to: "Rendered more hooks than during the previous render."
What makes this error particularly insidious is its intermittent nature. As documented in GitHub issue #63388, the crash doesn't happen 100% of the time. Sometimes users need to hard-refresh multiple times before encountering it, which suggests a race condition is at play.
The issue affects:
next build && next start)notFound() callsSuspense boundaries (explicitly or via loading.tsx)To understand why this Minified React error #310 occurs specifically with notFound in suspended server components, we need to understand how Next.js handles streaming and Suspense boundaries.
When you add a loading.tsx file to a route segment, Next.js automatically wraps your page in a Suspense boundary. This is a convenience feature that enables instant loading states while your async server components stream their content.
app/
├── [locale]/
│ ├── layout.tsx
│ ├── loading.tsx ← Creates an implicit Suspense boundary
│ ├── not-found.tsx ← Rendered when notFound() is called
│ └── product/
│ └── [id]/
│ └── page.tsx ← Your page with notFound() call
The Next.js documentation explicitly states that loading.js "wraps a page or child segment in a React Suspense Boundary." This wrapping happens at the framework level, not in your code.
When notFound() is called inside a component that's wrapped in a Suspense boundary (whether explicit or implicit via loading.tsx), React begins a complex dance:
notFound() call, which throws a special NEXT_HTTP_ERROR_FALLBACK;404 errornot-found.tsx pageHere's where the problem emerges. If your not-found.tsx page (or the layout wrapping it) contains a next/link component, React's hydration process encounters a mismatch.
The Link component from next/link internally uses several React hooks for prefetching, navigation state, and router integration. During the streaming process when notFound() is triggered from within a Suspense boundary, there's a timing issue:
<Link> components are called in a different order or quantity than the previous renderThis is essentially a race condition. If the data fetch takes longer (say, 500ms instead of 50ms), the error is less likely to occur because the timing aligns better. But with fast responses, the race condition is more likely to trigger.
// app/[locale]/not-found.tsx
import Link from "next/link";
export default function NotFound() {
return (
<div>
<h1>Page Not Found</h1>
{/* This Link component causes the conflict! */}
<Link href="/">Go back home</Link>
</div>
);
}The same issue applies to <Link> components in your layout that wrap the not-found page. Any next/link rendered alongside the not-found response can trigger this error.
Now that we understand the root cause, let's implement solutions. There are several approaches, ranging from quick fixes to architectural improvements.
The most straightforward fix is to replace next/link components with standard HTML anchor tags in your not-found page:
// app/[locale]/not-found.tsx - FIXED VERSION
export default function NotFound() {
return (
<div className="not-found-container">
<h1>404 - Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
{/* Use native anchor instead of next/link */}
<a href="/" className="back-home-link">
Go back home
</a>
</div>
);
}This eliminates the hook mismatch issue entirely. The trade-off is that you lose client-side navigation benefits (prefetching, no full page reload), but for a 404 page, this is usually acceptable since users are already in an error state and a full navigation reset is often appropriate.
If you need the navigation benefits of next/link, you can create a wrapper component that conditionally renders:
// components/SafeLink.tsx
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
interface SafeLinkProps {
href: string;
children: React.ReactNode;
className?: string;
fallbackClassName?: string;
}
export function SafeLink({
href,
children,
className,
fallbackClassName,
}: SafeLinkProps) {
const
Then use this in your not-found page:
// app/[locale]/not-found.tsx
import { SafeLink } from "@/components/SafeLink";
export default function NotFound() {
return (
<div className="not-found-container">
<h1>404 - Page Not Found</h1>
<SafeLink href="/">Go back home</SafeLink>
</div>
);
}A more architectural solution is to restructure your code so that notFound() is called outside any Suspense boundary:
// app/[locale]/product/[id]/page.tsx - RESTRUCTURED
import { notFound } from "next/navigation";
import { Suspense } from "react";
// Data validation happens at the page level, BEFORE Suspense
async function validateProductExists(id: string): Promise<boolean> {
const response = await fetch(`/api/products/${id}/exists`, {
next: { revalidate: 60 },
});
return response.ok;
}
// Product details rendering is separate and safe
async function ProductDetails({
This approach ensures that notFound() is called at the page level, before any Suspense boundary is entered, eliminating the race condition entirely.
If you don't explicitly need streaming for a particular route, removing the loading.tsx file eliminates the implicit Suspense boundary:
app/
├── [locale]/
│ ├── layout.tsx
│ ├── not-found.tsx
│ └── product/
│ └── [id]/
│ ├── page.tsx
│ └── loading.tsx ← Consider removing this
However, this means users won't see instant loading states for this route. You can still use manual Suspense boundaries for specific components while keeping notFound() at the page level.
The most frustrating aspect of Minified React error #310 in suspended server components is that it only appears in production. Here's how to catch these issues before your users do.
Always test your not-found flows with production builds:
# Build for production
npm run build
# Start production server
npm run start
# Or in one command
npm run build && npm run startThen specifically test:
notFound()Add integration tests using Playwright that specifically target production builds:
// e2e/not-found.spec.ts
import { test, expect } from "@playwright/test";
test.describe("notFound handling in production", () => {
test("should render 404 page without React errors", async ({ page }) => {
// Listen for console errors
const consoleErrors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text());
}
Include production build testing in your CI pipeline:
# .github/workflows/test.yml
name: Test Production Build
on: [push, pull_request]
jobs:
test-production:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Build for production
run: npm run build
- name
Add proper error boundaries that capture and report these errors:
// app/error.tsx
"use client";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log to your error tracking service
console.error("Application error:", error);
// Send to Sentry, LogRocket, etc.
// reportError(error)
}, [error]);
return
notFound() is called inside a Suspense boundary (explicit or implicit via loading.tsx) and the not-found page contains next/link componentsnext/link with native <a> tags in your not-found.tsx pagenotFound() calls outside of Suspense boundaries by validating data at the page level before entering suspended componentsnotFound() calls inside Suspense boundaries or routes with loading.tsx filesnext/link usage—consider replacing with native anchors