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

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.
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.
| Perspective | Pros | Cons |
|---|---|---|
| User experience | Same import path regardless of environment | You cannot see which code is actually wired at build time |
| Debugging | - | Hard to understand error causes when importing useRouter in Server Components |
| Maintainability | Public API stability even when internals change | The 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.
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.
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.
null! trickLook 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.
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.
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 buildReact 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.
| Hook | Client build (react.production.js) | Server build (react.react-server.production.js) |
|---|---|---|
useContext | yes (line 511) | no |
useState | yes | no |
useEffect | yes (line 518) | no |
useMemo | yes (line 536) | yes (line 433) |
useCallback | yes (line 508) | yes (line 426) |
use | yes (line 502) | yes (line 423) |
createContext | yes | no |
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.
This is how the failure happened when only one navigation.ts existed:
redirect from next/navigation.navigation.ts.navigation.ts includes import { useContext } from 'react'.react-server condition.react.react-server.js does not export useContext."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/navigationit will error with bundling that you're attempted to import some client component hooks such asuseContextfrom react. So we introduced areact-serverversion ofnext/navigationthat doesn't interoplate with any client hooks.
The git history gives a clear timeline.
| Date | PR | Author | Change |
|---|---|---|---|
| 2024-02-05 | #61522 | Josh Story (React team) | shared-subset -> react-server, removed useContext |
| 2024-02-26 | #62456 | Jiachi 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.
Here is a summary of the complexity we traced in this article.
| Complexity | Cause | Evidence |
|---|---|---|
| Context + adapter pattern | Pages/App Router share the same hooks | TODO in app-router.tsx:549-554 |
.react-server.ts split | useContext removed from React server build | PR #61522 -> PR #62456 |
null! type trick | Conditional compat overloads for mixed Pages/App projects | writeAppTypeDeclarations.ts:59-62 |
| Build-time alias system | Transparent server/client branching | create-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.