How to Understand a Next.js Codebase: App Router to Middleware

A field guide to reading a Next.js App Router project: which files matter, where the server/client line sits, how requests flow, and what to check first.

By The OpenVisio Team · · 13 min read

A Next.js project hides a lot of its structure in file names. In a plain Node service you can open the entry point and follow imports. In a Next.js App Router project, the router is the file system, the framework decides what runs on the server and what runs in the browser based on a string at the top of a file, and a single folder can contain a page, its layout, its loading state, its error boundary and an API endpoint, none of which import each other.

That makes Next.js codebases fast to build and slightly disorienting to read. This guide gives you an order of operations for understanding one you did not write: what to open first, how to read the routing tree, where the server/client boundary sits, how data is fetched and cached, and where cross-cutting behavior like auth usually lives. It focuses on the App Router (the app/ directory), with a short section on the older Pages Router because many real projects still have both.

Key takeaways

  • Start with package.json, next.config.* and the top of the app/ directory. Those three tell you the Next.js version, build-time behavior and the shape of every route.
  • In the App Router, folders define URL segments and special files (page, layout, route, loading, error, not-found) define what renders at each segment.
  • Every component is a Server Component unless its file, or a file that imports it, starts with 'use client'. Find those directives first; they are the boundary of the browser bundle.
  • Route handlers (route.ts) and Server Actions ('use server') are the backend. Middleware (renamed proxy.ts in Next.js 16) runs before both.
  • Run next build once. The route table it prints tells you which routes are static and which are dynamic faster than reading code.
  • Data access is often wrapped in a lib/ or server/ folder. Find it early, because that is where business logic actually lives.

Step 1: Pin down the version and the router

Open package.json and note the next, react and react-dom versions. The App Router became stable in Next.js 13.4, caching defaults changed significantly in 15, and 16 renamed middleware to proxy and introduced the 'use cache' directive behind the cacheComponents flag.

Then check which router is in use:

ls -d app src/app pages src/pages 2>/dev/null

If you see app/, it is the App Router. If you see pages/, it is the Pages Router. If you see both, the project is mid-migration, and a given URL is served by whichever directory defines it (a route defined in both is a build error). The src/ variants are just an optional wrapper folder; Next.js treats src/app exactly like app.

Next, read next.config.js, next.config.mjs or next.config.ts. Look for:

  • redirects(), rewrites() and headers(), which change routing before your code runs. A rewrite can make /docs serve content from a different path or a different host entirely.
  • images.remotePatterns, which tells you which external image hosts the app depends on.
  • output: 'standalone' or output: 'export', which tells you how the app is deployed (a Node server versus a static export).
  • experimental flags and transpilePackages, which often point at monorepo packages the app imports from source.

Step 2: Read the routing tree from the file system

In the App Router, each folder under app/ is a URL segment, and a segment only becomes a reachable page when it contains a page.tsx (or .js, .jsx, .ts). Print the tree with only the files that matter:

find app -type f \( -name 'page.*' -o -name 'layout.*' -o -name 'route.*' \
  -o -name 'loading.*' -o -name 'error.*' -o -name 'not-found.*' -o -name 'template.*' \) | sort

That listing is the closest thing a Next.js app has to a routes file. Read it with these conventions in mind:

ConventionExampleMeaning
Static segmentapp/settings/page.tsx/settings
Dynamic segmentapp/posts/[slug]/page.tsx/posts/anything, with params.slug
Catch-allapp/docs/[...path]/page.tsx/docs/a/b/c, params.path is an array
Optional catch-allapp/shop/[[...filters]]/page.tsxAlso matches /shop with no segments
Route groupapp/(marketing)/about/page.tsx/about; the parenthesized folder is not in the URL
Private folderapp/_components/Button.tsxNot routable; used for colocated code
Parallel routeapp/dashboard/@analytics/page.tsxA named slot rendered by the dashboard layout
Intercepting routeapp/feed/(..)photo/[id]/page.tsxShows /photo/[id] inside /feed, usually as a modal

Route groups are the convention most likely to confuse you. Teams use them to give different sections different root layouts, for example (marketing) with a public header and (app) with an authenticated shell. If two groups each have their own layout.tsx with <html> and <body>, the app has multiple root layouts, and navigating between them causes a full page load.

Step 3: Read layouts from the outside in

Layouts wrap every page beneath them and persist across navigations. Read them in order from app/layout.tsx downward, because that is the order in which they wrap each other. The root layout is where you usually find:

  • Global providers: theme, auth session, analytics, a React Query or SWR client, toast containers.
  • Fonts loaded via next/font.
  • The default metadata export or generateMetadata function for SEO.

Nested layouts typically add navigation shells and, importantly, auth checks. A pattern you will see often is a layout under (app)/ that reads the session on the server and redirects:

// app/(app)/layout.tsx
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'

export default async function AppLayout({ children }: { children: React.ReactNode }) {
  const session = await getSession()
  if (!session) redirect('/login')
  return <Shell user={session.user}>{children}</Shell>
}

Note that layouts do not re-render on navigation between their child pages, so an auth check in a layout is not a complete security boundary on its own. Look for the same check in route handlers, Server Actions and the data layer. If you only find it in the layout, that is worth raising.

Also read the sibling files: loading.tsx wraps the page in a Suspense boundary, error.tsx is an error boundary (and must be a Client Component), and not-found.tsx renders when code calls notFound().

Step 4: Find the server/client boundary

In the App Router, components are Server Components by default. They run on the server, can be async, can read the database directly, and ship no JavaScript to the browser. A file opts into the client by starting with the 'use client' directive, and everything that file imports becomes part of the client bundle too.

So the single most useful search in a Next.js codebase is:

grep -rl "^['\"]use client['\"]" app components src 2>/dev/null | sort

This gives you the list of client entry points. Everything not reachable from them is server-only. When you read the list, ask three questions:

  1. Is the directive at the right depth? A 'use client' at the top of a large page component pulls the whole subtree into the browser. Well-structured apps push it down to small interactive leaves like buttons, forms and dropdowns.
  2. Are server-only modules protected? Look for import 'server-only' in files that touch secrets or the database. Without it, a client file can accidentally import a module that reads process.env.DATABASE_URL, and you will only find out at build time or, worse, by leaking a value that was prefixed NEXT_PUBLIC_.
  3. How do server data and client interactivity meet? The common pattern is a Server Component that fetches data and passes plain serializable props to a Client Component. Props crossing that line must be serializable, so you will not see functions passed down except Server Actions.

Also search for 'use server'. At the top of a file it marks every exported async function as a Server Action; inside a function body it marks just that function. Server Actions are called from forms and client code but run on the server, so they are part of the backend even though they live next to UI.

Step 5: Map the backend surfaces

A Next.js app usually has three ways for the browser to reach server code, plus one layer that runs before all of them.

Route handlers. A route.ts file exports functions named after HTTP methods:

// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
  const body = await request.text()
  // verify signature, handle event
  return Response.json({ received: true })
}

A route.ts cannot live in the same segment as a page.tsx. Most projects keep them under app/api/, but they can appear anywhere, so rely on the find command above instead of assuming.

Server Actions. Found via 'use server', as described above. These are often where mutations live: create, update, delete. Check that each one validates input and checks authorization, because an action is a public POST endpoint whether or not the UI exposes it.

Server Components that read data directly. A page can await db.query(...) in its body. That is a read path with no API in between.

Middleware or proxy. A single middleware.ts (or proxy.ts in Next.js 16) at the project root, or inside src/, runs before matched requests. It is commonly used for auth redirects, locale detection, A/B bucketing and rewrites. Read its config.matcher first to know which paths it touches:

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

If an unexpected redirect or header shows up, middleware is the first place to look. Also check for instrumentation.ts, which runs once at server startup and is where tracing and monitoring are usually registered.

Step 6: Understand data fetching and caching

Caching is the part of Next.js where reading the code is least sufficient, because behavior depends on the version and on segment config. Look for these signals:

  • export const dynamic = 'force-dynamic' or 'force-static' at the top of a page or layout.
  • export const revalidate = 60, which sets time-based revalidation for the segment.
  • fetch(url, { next: { revalidate: 3600, tags: ['posts'] } }) and { cache: 'no-store' } options on individual fetches.
  • revalidatePath() and revalidateTag() calls, usually inside Server Actions after a mutation. Search for them to learn which writes invalidate which pages.
  • unstable_cache, React's cache(), or the newer 'use cache' directive.
  • generateStaticParams, which pre-renders dynamic segments at build time.

Then run a production build. It does more to explain caching than any amount of reading:

npm run build   # or: npx next build

The output ends with a route table where each route is marked as static (prerendered as HTML) or dynamic (rendered on each request), along with bundle sizes. A route you expected to be static that shows as dynamic usually means something in its tree reads cookies(), headers() or search params. That is a common source of performance surprises, and the build output points you straight at it.

Step 7: Locate the real business logic

In most Next.js apps, pages and components are thin, and the logic lives in a few folders that the framework knows nothing about: lib/, server/, services/, db/, actions/, or a monorepo package. Check tsconfig.json for path aliases like "@/*": ["./src/*"] so you can read imports correctly, then find the data layer:

grep -rn "from '@/lib/db'\|prisma\.\|drizzle(" --include=*.ts --include=*.tsx . | head -40

Once you know where queries are defined, pick one user-facing feature and follow it end to end: the page that renders it, the Server Component that fetches its data, the function in lib/ that runs the query, and the Server Action or route handler that mutates it. That single trace teaches you more about conventions than reading ten files at random. The general method is covered in tracing a request through a web app.

If the project uses the Pages Router

Older code, or code mid-migration, uses pages/. The equivalents:

Pages RouterApp Router equivalent
pages/_app.tsxapp/layout.tsx (root layout and providers)
pages/_document.tsxThe <html> and <body> in the root layout
pages/api/*.tsapp/**/route.ts
getServerSidePropsAn async Server Component, dynamic rendering
getStaticProps + getStaticPathsStatic rendering + generateStaticParams

A one-hour checklist

  1. Note the Next.js and React versions from package.json.
  2. Read next.config.* for redirects, rewrites, images and output mode.
  3. List the route files with find and sketch the URL tree, including route groups.
  4. Read the root layout and every nested layout, noting providers and auth checks.
  5. List 'use client' and 'use server' files.
  6. Read middleware.ts or proxy.ts and its matcher.
  7. Run next build and read the route table.
  8. Find the data layer and trace one feature from page to database.

If you are coming from plain React, it helps to read how to understand a React codebase alongside this, since client components follow the same patterns. And if you want a visual starting point, Codebase Knowledge by OpenVisio can index a Next.js repo in your browser and show the module graph in Explore, which makes it easier to see which lib/ modules every route leans on before you start opening files.

Next.jsApp RouterReact Server Componentscode reading

See it on your own repo

Paste a GitHub URL and get the day-one walkthrough — free, in your browser.

Index a repo

Keep reading