본문으로 건너뛰기
KYH
  • Blog
  • About

joseph0926

I document what I learn while solving product problems with React and TypeScript.

HomeBlogAbout

© 2026 joseph0926. All rights reserved.

nextjssource-code

Why is Next.js source code so complex? -- The cost of keeping Pages Router

Tracing just next/navigation through the source reveals where the complexity comes from: re-export entry points, context adapters, and .react-server.ts splits.

Feb 21, 20267 min read
Why is Next.js source code so complex? -- The cost of keeping Pages Router

Why is Next.js source code so complex?

These days, I am studying by reading the Next.js source code directly. Learning Github

Just like when I studied React, I fork the repo locally and open files one by one. To read source efficiently, I start from APIs I already use often. So I started from next/navigation, especially useRouter and redirect.

import { useRouter, redirect } from 'next/navigation';

It is just one import line. But once I traced it, it went through far more files than expected.

[Default entry]
next/navigation (packages/next/navigation.js)
  -> dist/client/components/navigation

[When webpack alias layers apply]
next/navigation.js
  -> next/dist/api/navigation (createNextApiEsmAliases)
  -> (server-only layer) next/dist/api/navigation.react-server

[Source-level mapping]
src/api/navigation.ts (re-export layer)
  -> client/components/navigation.ts (actual implementation)
    -> navigation.react-server.ts (server-only branch)

The complexity came from two different axes. Context/adapter/null-compat came from Pages Router coexistence, while the .react-server.ts split was directly triggered by the React react-server condition transition and bundling issue (#62456).

This post is a record of what I found while tracing just next/navigation.


Help desk pattern -- Why does src/api/ exist?

In Next.js, packages/next/src/api/ contains 16 files.

src/api/
|- navigation.ts
|- navigation.react-server.ts
|- headers.ts
|- server.ts
|- image.ts
|- link.ts
|- form.ts
|- script.ts
|- og.ts
|- dynamic.ts
|- app-dynamic.ts
|- router.ts
|- head.ts
|- document.tsx
|- app.tsx
`- constants.ts

If you open them, they are short.

// src/api/navigation.ts -- full file
export * from '../client/components/navigation';
// src/api/headers.ts -- full file
export * from '../server/request/cookies';
export * from '../server/request/headers';
export * from '../server/request/draft-mode';
// src/api/server.ts -- full file
export * from '../server/web/exports/index';

There is no implementation here. They only re-export from elsewhere. src/api/ is like a hotel help desk. The desk does not provide the service itself, but guests (developers) only need to visit one place. Actual service happens in departments like client/, server/, and shared/.

This pattern becomes powerful when combined with the build system.

// build/create-compiler-aliases.ts:231-250
export function createAppRouterApiAliases(isServerOnlyLayer: boolean) {
  const mapping: Record<string, string> = {
    head: 'next/dist/client/components/noop-head',
    dynamic: 'next/dist/api/app-dynamic',
    link: 'next/dist/client/app-dir/link',
    form: 'next/dist/client/app-dir/form',
  };

  if (isServerOnlyLayer) {
    mapping['navigation'] = 'next/dist/api/navigation.react-server';
    mapping['link'] = 'next/dist/client/app-dir/link.react-server';
  }

  const aliasMap: Record<string, string> = {};
  for (const [key, value] of Object.entries(mapping)) {
    const nextApiFilePath = path.join(NEXT_PROJECT_ROOT, key);
    aliasMap[nextApiFilePath + '.js'] = value;
  }
  return aliasMap;
}

If isServerOnlyLayer is true (while bundling Server Components), navigation resolves to navigation.react-server. Developers still import 'next/navigation', and the build system connects the environment-specific target.

It is like the help desk checking your room type and routing you to the right service.

Pros and cons come from the same root.

PerspectiveProsCons
User experienceSame import path regardless of environmentYou cannot see which code is actually wired at build time
Debugging-

Hard to understand error causes when importing useRouter in Server Components

MaintainabilityPublic API stability even when internals changeThe alias system itself adds complexity

This is the double edge of abstraction. It makes users comfortable, but makes root-cause tracing harder when problems happen.

Among those 16 files, only navigation.ts has a .react-server.ts pair. Why does only this one need a server/client split?

To answer that, we need to open the real implementation behind navigation.ts.


The cost of two routers sharing one hook

Why context is needed

If you open the actual implementation (client/components/navigation.ts), all hooks follow the same pattern.

// client/components/navigation.ts:146-153
export function useRouter(): AppRouterInstance {
  const router = useContext(AppRouterContext);
  if (router === null) {
    throw new Error('invariant expected app router to be mounted');
  }
  return router;
}
// client/components/navigation.ts:103-108
export function usePathname(): string {
  const pathname = useContext(PathnameContext) as string;
  return pathname;
}
// client/components/navigation.ts:56-68
export function useSearchParams(): ReadonlyURLSearchParams {
  const searchParams = useContext(SearchParamsContext);
  const readonlySearchParams = useMemo((): ReadonlyURLSearchParams => {
    if (!searchParams) {
      return null!;
    }
    return new ReadonlyURLSearchParams(searchParams);
  }, [searchParams]);
  return readonlySearchParams;
}

useRouter reads AppRouterContext, usePathname reads PathnameContext, and useSearchParams reads SearchParamsContext. They all read values injected by the framework through useContext.

Why context? Why not import concrete implementations directly?

app-router.tsx gives the answer.

// client/components/app-router.tsx:549-554
{/* TODO: We should be able to remove this context. useRouter
    should import from app-router-instance instead. It's only
    necessary because useRouter is shared between Pages and
    App Router. We should fork that module, then remove this
    context provider. */}
<AppRouterContext.Provider value={publicAppRouterInstance}>

This TODO is from the Next.js team itself. Context is needed because useRouter must work for both Pages Router and App Router. The comment explicitly says context can be removed after forking.

Different plumbing, different water

Then how does the same useRouter() know whether it is running under Pages Router or App Router?

It does not detect anything. There is no explicit detection logic.

In Pages Router AppContainer (client/index.tsx:296-334):

// client/index.tsx:296-334
function AppContainer({ children }): React.ReactElement {
  const adaptedForAppRouter = React.useMemo(() => {
    return adaptForAppRouterInstance(router);
  }, []);
  return (
    <Container>
      <AppRouterContext.Provider value={adaptedForAppRouter}>
        <SearchParamsContext.Provider value={adaptForSearchParams(router)}>
          <PathnameContextProviderAdapter
            router={router}
            isAutoExport={/* ... */}
          >
            <PathParamsContext.Provider value={adaptForPathParams(router)}>
              {children}
            </PathParamsContext.Provider>
          </PathnameContextProviderAdapter>
        </SearchParamsContext.Provider>
      </AppRouterContext.Provider>
    </Container>
  );
}

Compare that with App Router provider setup (client/components/app-router.tsx:536-558):

// client/components/app-router.tsx:536-558
return (
  <>
    <PathParamsContext.Provider value={pathParams}>
      <PathnameContext.Provider value={pathname}>
        <SearchParamsContext.Provider value={searchParams}>
          <GlobalLayoutRouterContext.Provider value={globalLayoutRouterContext}>
            <AppRouterContext.Provider value={publicAppRouterInstance}>
              <LayoutRouterContext.Provider value={layoutRouterContext}>
                {content}
              </LayoutRouterContext.Provider>
            </AppRouterContext.Provider>
          </GlobalLayoutRouterContext.Provider>
        </SearchParamsContext.Provider>
      </PathnameContext.Provider>
    </PathParamsContext.Provider>
  </>
);

They inject different values into the same contexts (AppRouterContext, SearchParamsContext, PathnameContext). Pages Router injects an adapted object from adaptForAppRouterInstance(router), while App Router injects publicAppRouterInstance.

The hook is not deciding "where am I?". The plumbing (provider tree) is already wired differently. useContext simply reads the nearest parent provider value, so the return value changes based on where the component is rendered.

[Pages Router path]
AppContainer
  -> AppRouterContext.Provider value={adaptedForAppRouter}
     -> User's Pages component
        -> useRouter() -> useContext(AppRouterContext) -> adapter object

[App Router path]
app-router.tsx
  -> AppRouterContext.Provider value={publicAppRouterInstance}
     -> User's App component
        -> useRouter() -> useContext(AppRouterContext) -> App Router instance

This is enabled by the adapter function.

// shared/lib/router/adapters.tsx:12-36
export function adaptForAppRouterInstance(
  pagesRouter: NextRouter,
): AppRouterInstance {
  return {
    back() {
      pagesRouter.back();
    },
    forward() {
      pagesRouter.forward();
    },
    refresh() {
      pagesRouter.reload();
    },
    push(href, { scroll } = {}) {
      void pagesRouter.push(href, undefined, { scroll });
    },
    replace(href, { scroll } = {}) {
      void pagesRouter.replace(href, undefined, { scroll });
    },
    prefetch(href) {
      void pagesRouter.prefetch(href);
    },
  };
}

It converts Pages Router NextRouter into the AppRouterInstance interface. For example, refresh() internally calls pagesRouter.reload(). Since Pages Router cannot be dropped yet, an adapter provides one shared interface.

Type-system impact too -- the null! trick

Look at useSearchParams again.

// client/components/navigation.ts:64-68
if (!searchParams) {
  // When the router is not ready in pages, we won't have the search params
  // available.
  return null!;
}

null! applies TypeScript non-null assertion to null. The return type is ReadonlyURLSearchParams, but under Pages Router there are real cases where null must be returned. The comment says exactly why: search params are unavailable while the Pages router is not ready.

In App-only projects, this cannot happen, so the base type does not include | null. Instead, projects that use both Pages and App routers receive an additional compat type overload automatically.

// navigation-types/compat/navigation.d.ts
declare module 'next/navigation' {
  export function useSearchParams(): ReadonlyURLSearchParams | null;
  export function usePathname(): string | null;
  export function useParams<
    T extends Record<string, string | string[]>,
  >(): T | null;
  export function useSelectedLayoutSegments(): string[] | null;
  export function useSelectedLayoutSegment(): string | null;
}

The condition is in writeAppTypeDeclarations.ts.

// lib/typescript/writeAppTypeDeclarations.ts:59-62
if (hasAppDir && hasPagesDir) {
  lines.push(
    '/// <reference types="next/navigation-types/compat/navigation" />',
  );
}

hasAppDir && hasPagesDir means the | null overloads are added only when both routers coexist. App-only projects can use these hooks without null checks; mixed projects get null checks enforced by TypeScript.

It is a clever solution, but the fact that null! is needed is itself a signal of complexity. Without Pages Router support, this code would not exist.


When React changes, Next.js gets shaken too

Earlier we saw that only navigation.ts has a .react-server.ts pair in src/api/. Why did that file appear? The answer is visible when tracking PR history.

Two kinds of APIs in one file

navigation.ts (client version) mixes two very different API categories.

// client/components/navigation.ts
// 1. React hooks -- useContext based, client-only
import React, { useContext, useMemo, use } from 'react';

export function useRouter(): AppRouterInstance {
  const router = useContext(AppRouterContext);
  // ...
}
export function usePathname(): string {
  const pathname = useContext(PathnameContext) as string;
  // ...
}

// 2. Pure functions -- shared by server and client
export {
  notFound,
  redirect,
  permanentRedirect,
  RedirectType,
  unstable_rethrow,
} from './navigation.react-server';

.react-server.ts (server version) has no context-dependent hooks, only shared server APIs and a guard function.

// client/components/navigation.react-server.ts -- full file
export function unstable_isUnrecognizedActionError(): boolean {
  throw new Error(
    '`unstable_isUnrecognizedActionError` can only be used on the client.',
  );
}

export { redirect, permanentRedirect } from './redirect';
export { notFound } from './not-found';
export { forbidden } from './forbidden';
export { unauthorized } from './unauthorized';
export { unstable_rethrow } from './unstable-rethrow';
export { ReadonlyURLSearchParams };
export const RedirectType = { push: 'push', replace: 'replace' } as const;

Originally there was only one navigation.ts. The .react-server.ts file was introduced because of a React-side change.

useContext disappeared from the server build

React separates server and client builds through conditional exports in package.json.

// compiled/react/package.json
".": {
  "react-server": "./react.react-server.js",
  "default": "./index.js"
}

The available hooks differ across those builds.

HookClient build (react.production.js)Server build (react.react-server.production.js)
useContextyes (line 511)no
useStateyesno
useEffectyes (line 518)no
useMemoyes (line 536)yes (line 433)
useCallbackyes (line 508)yes (line 426)
useyes (line 502)yes (line 423)
createContextyesno

From React's viewpoint this is reasonable: context APIs are not meaningful in Server Components.

The issue is that Next.js navigation.ts was affected by this change.

Error path

This is how the failure happened when only one navigation.ts existed:

  1. A Server Component imports redirect from next/navigation.
  2. The bundler resolves navigation.ts.
  3. navigation.ts includes import { useContext } from 'react'.
  4. Because this is a server bundle, React resolves with the react-server condition.
  5. react.react-server.js does not export useContext.
  6. Bundling fails: "useContext is not exported from react".

The user only wanted redirect, but useRouter in the same file imported useContext, and that symbol does not exist in React's server build.

The commit message of PR #62456 explains this directly.

We found that if you're using edge runtime with next/navigation it will error with bundling that you're attempted to import some client component hooks such as useContext from react. So we introduced a react-server version of next/navigation that doesn't interoplate with any client hooks.

Timeline -- 3 weeks between React and Next.js

The git history gives a clear timeline.

DatePRAuthorChange
2024-02-05#61522Josh Story (React team)shared-subset -> react-server, removed useContext
2024-02-26#62456Jiachi Liu (Next.js team)Added navigation.react-server.ts to fix bundling error

In PR #61522, Josh Story updated React and changed Next.js alias naming (shared-subset -> react-server) in one PR, so this was a coordinated effort. But that PR mostly handled alias naming, and did not fully catch the navigation.ts + useContext impact.

Three weeks later, real Edge Runtime + Server Components errors were reported, and the Next.js team shipped the .react-server.ts split as a follow-up fix.

So this was not an intentional breaking change from Next.js. React intentionally removed useContext from the server build, but the impact surface in Next.js was not fully handled in one step. This is a realistic failure mode in framework-level dependency coordination.


Wrap-up

Here is a summary of the complexity we traced in this article.

ComplexityCauseEvidence
Context + adapter patternPages/App Router share the same hooksTODO in app-router.tsx:549-554
.react-server.ts splituseContext removed from React server buildPR #61522 -> PR #62456
null! type trickConditional compat overloads for mixed Pages/App projectswriteAppTypeDeclarations.ts:59-62
Build-time alias systemTransparent server/client branchingcreate-compiler-aliases.ts:231-250

The key is two axes. Context/adapter/null! complexity comes from Pages Router coexistence, while the .react-server.ts split was directly triggered by a React react-server transition side effect and bundling issue (#62456). As the TODO says, if Pages/App modules are forked, context coupling can be reduced.

But Next.js still cannot drop Pages Router entirely. Many production projects still run on it, and Pages Router is still supported in Next.js v16. That is why the source is complex. This complexity is not simply bad design; it is the result of practical compatibility constraints.