Fix: NextRouter was not mounted

useRouter() throws this when it runs outside a real Pages Router tree — unit tests, Storybook, or an App Router migration. The three call-site fixes.

Mahdi Ben RhoumaMahdi Ben Rhouma · Updated

NextRouter was not mounted is thrown by useRouter() from next/router the moment it runs in a tree that Next.js itself did not render. It is not a router bug — it is useRouter() reading a React Context (RouterContext) that only <App> populates, and three unrelated situations remove that provider: unit tests, Storybook stories, and components moved to the App Router. Each needs a different fix.

Why it throws#

next/router's useRouter() calls useContext(RouterContext) and throws when the context value is null instead of silently returning undefined. Next.js's internal <App> component wraps every page in RouterContext.Provider during a real next dev/next build render, so the error never appears in the browser for a page that Next.js actually served. It appears the instant a component that calls useRouter() gets rendered by something else — Jest, React Testing Library, Storybook, or an App Router tree that has no Pages Router at all.

Cause 1: unit tests with Jest / React Testing Library#

tsx
// ❌ throws "NextRouter was not mounted"
import { render } from '@testing-library/react';
import { Nav } from './Nav'; // calls useRouter() internally
 
test('renders nav', () => {
  render(<Nav />);
});

render() mounts <Nav /> in a bare test DOM — no RouterContext.Provider anywhere in the tree. The fix is next-router-mock, which drops in as a same-shaped replacement for next/router and gives you a router object without needing the real framework runtime:

bash
npm install --save-dev next-router-mock
tsx
// jest.setup.js or the test file itself
jest.mock('next/router', () => require('next-router-mock'));
tsx
import { render } from '@testing-library/react';
import mockRouter from 'next-router-mock';
import { Nav } from './Nav';
 
test('renders nav', () => {
  mockRouter.push('/dashboard');
  render(<Nav />);
});

If you cannot add a dependency, the cheaper stopgap is mocking next/router directly with jest.mock, returning a plain object with the fields your component reads (pathname, query, push, replace):

tsx
jest.mock('next/router', () => ({
  useRouter: () => ({
    pathname: '/dashboard',
    query: {},
    push: jest.fn(),
    replace: jest.fn(),
  }),
}));

This is more brittle — every field your component starts using later needs to be added by hand — which is why next-router-mock is the fix that survives refactors.

Cause 2: Storybook stories#

Same root cause, different renderer: Storybook mounts the component in isolation, outside any Next.js page tree.

tsx
// .storybook/preview.js
import { RouterContext } from 'next/dist/shared/lib/router-context.shared-runtime';
import { createMockRouter } from './mocks/router';
 
export const parameters = {
  nextRouter: { Provider: RouterContext.Provider },
};

The maintained path here is the storybook-addon-next-router (or storybook-addon-nextjs-router for newer Storybook majors) addon — it wraps every story in the provider automatically and exposes router state as a per-story parameter, so you configure it once instead of wrapping each story manually.

Cause 3: the component actually lives in the App Router now#

This is the version that looks like a regression during a migration. If the component that calls useRouter() from next/router was moved under app/, there is no Pages Router <App> anywhere in the tree to provide the context — the App Router does not use next/router at all. The fix is not a mock, it is the correct import:

tsx
// ❌ Pages Router hook, throws in app/
import { useRouter } from 'next/router';
 
// ✅ App Router hook
import { useRouter } from 'next/navigation';

next/navigation's useRouter() has a smaller API — no router.query (read useParams() / useSearchParams() instead), and router.push() takes no second as/options argument the way the Pages Router version did. A component that only swaps the import but keeps reading router.query will compile and then fail at runtime with query being undefined, which reads like a second, unrelated bug — check every router.query reference when you do this swap.

Verifying the fix#

  1. grep -rn "from 'next/router'" src app __tests__ — every hit under app/ is the Cause 3 bug regardless of whether it currently throws.
  2. Run the failing test/story again with the mock or the corrected import; the error disappears without touching the component under test.
  3. If the component is shared between a Pages Router page and an App Router page, split it — one file cannot import both router hooks safely.

Testing a custom hook that calls useRouter, not a component#

The fixes above all wrap a rendered component — but a custom hook built on top of useRouter() (something like useCurrentQuery() or useNavigateWithConfirm()) hits the same "NextRouter was not mounted" error when tested with @testing-library/react's renderHook(), and the fix looks slightly different because there's no component tree to render the mock inside of directly. renderHook() takes a wrapper option specifically for this: a component that supplies context around the hook under test, which is exactly where the router mock belongs:

tsx
import { renderHook } from '@testing-library/react';
import mockRouter from 'next-router-mock';
import { RouterContext } from 'next/dist/shared/lib/router-context.shared-runtime';
import { useCurrentQuery } from './useCurrentQuery';
 
function wrapper({ children }: { children: React.ReactNode }) {
  return (
    <RouterContext.Provider value={mockRouter}>{children}</RouterContext.Provider>
  );
}
 
test('reads the current query', () => {
  mockRouter.push('/search?q=next');
  const { result } = renderHook(() => useCurrentQuery(), { wrapper });
  expect(result.current).toBe('next');
});

Passing jest.mock('next/router', ...) at the top of the test file (as shown earlier in this page) still works too and is simpler when the hook only needs push/replace; the wrapper approach is worth reaching for once the hook reads several different router fields and a plain mock object becomes tedious to keep in sync. Either way, renderHook() without a router mock in place fails with the exact same error message as render() does, for the exact same underlying reason — there's still no RouterContext.Provider anywhere above the code under test. The broader test setup this fits into — what to mock and what to run against a real database — is covered in Testing Next.js + Supabase: Unit, Integration, RLS, E2E.

A different, unrelated error that looks similar: useSearchParams needs Suspense#

During an App Router migration it's easy to assume every next/navigation hook error is a variant of "NextRouter was not mounted" — it is not. next/navigation's useRouter() never throws that specific error; if a component using it renders inside app/ with no Pages Router anywhere in the tree, it works, because next/navigation doesn't rely on the Pages Router's RouterContext at all. What does throw during static generation, and gets confused with this bug because both surface while touching router hooks, is useSearchParams() requiring a <Suspense> boundary around any component that calls it — a separate constraint tied to how the App Router handles dynamic rendering, not to context being missing:

tsx
// ❌ throws "useSearchParams() should be wrapped in a suspense boundary"
export default function SearchBar() {
  const params = useSearchParams();
  return <input defaultValue={params.get('q') ?? ''} />;
}
tsx
// ✅ wrap the component that reads it
import { Suspense } from 'react';
 
export default function Page() {
  return (
    <Suspense>
      <SearchBar />
    </Suspense>
  );
}

This is a build-time constraint specific to useSearchParams(), not usePathname() or useRouter() from next/navigation, both of which render fine without a Suspense wrapper. Full coverage of this exact error and why the boundary has to sit above the component that calls the hook, not around the value itself, is in Fix: Missing Suspense Boundary with useSearchParams in Next.js.

Further reading: Next.js Query String Params: searchParams + useRouter.

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Related fixes & guides