I am a...
Learn more
How it worksPricingFAQ
Account
May 7, 2026 · 11 min read · Cadence Editorial

How to structure a Next.js project for scale

structure nextjs project scale — How to structure a Next.js project for scale
Photo by [Bibek ghosh](https://www.pexels.com/@bibekghosh) on [Pexels](https://www.pexels.com/photo/code-on-computer-screen-14553730/)

How to structure a Next.js project for scale

To structure a Next.js project for scale, keep app/ as a routing-only directory, group code by feature (not file type) under src/features/, isolate a single data-access layer with one Drizzle client, and enforce server-only and client-only boundaries at the import level. Everything else (env-typed config, route groups per team, Suspense streaming, monorepo packages) plugs into that spine.

That single decision, treating route segments as thin shells around feature modules, is what separates a Next.js codebase that survives 20 engineers from one that calcifies at 5.

The answer in one diagram

Here is the layout that holds up at 50,000 lines of code and beyond:

my-app/
├── app/                          # Routing only. No business logic here.
│   ├── (marketing)/              # Route group: public site
│   ├── (app)/                    # Route group: authenticated app
│   │   ├── billing/
│   │   │   ├── page.tsx          # Imports from features/billing
│   │   │   ├── loading.tsx
│   │   │   └── error.tsx
│   ├── api/
│   │   └── webhooks/
│   ├── layout.tsx
│   └── global-error.tsx
├── src/
│   ├── features/                 # Domain code. The bulk of the app.
│   │   ├── billing/
│   │   │   ├── server/           # Server actions, services
│   │   │   ├── client/           # Client components
│   │   │   ├── data/             # Drizzle queries
│   │   │   ├── schema.ts         # Zod + Drizzle schema
│   │   │   └── index.ts          # Public barrel
│   │   └── auth/
│   ├── lib/
│   │   ├── db.ts                 # Single Drizzle client
│   │   ├── env.ts                # Zod-validated env
│   │   └── i18n/
│   └── ui/                       # Generic, domain-agnostic components
├── tests/
└── turbo.json                    # If monorepo

Next.js 15 ships the App Router as the default and the docs assume it for new projects. Everything below builds on that.

Why the obvious folder structure breaks at the boundary

Most teams reach for a flat app/ directory plus a components/ and lib/ at the root. It works for the first five routes. It dies somewhere around fifty.

The failure modes are predictable. Server-only code (database calls, secret keys, server actions) starts getting imported by client components because the import graph never enforced the boundary. The Drizzle client gets re-instantiated per route handler, opening a new Postgres connection on every request, which on Vercel triggers connection storms during traffic spikes. Domain logic ends up half in app/billing/page.tsx and half in lib/billing.ts, and nobody knows which is canonical. Onboarding a new engineer becomes a tour of every folder.

The fix is not "more folders." The fix is treating route segments as the thinnest possible shell around feature modules that own their server, client, and data seams.

Feature folders, not route segments

Group code by what it does for the user, not by what file type it is. A src/features/billing/ folder owns every line of code that implements billing: schema, server actions, queries, client components, types, and tests. The route segment at app/(app)/billing/page.tsx is fewer than 30 lines and just composes the feature.

// app/(app)/billing/page.tsx
import { BillingDashboard } from "@/features/billing"
import { getCurrentSubscription } from "@/features/billing/server"

export default async function Page() {
  const sub = await getCurrentSubscription()
  return <BillingDashboard subscription={sub} />
}

Inside src/features/billing/index.ts you export only the public surface: components and types meant to be consumed by routes. Everything else stays internal. This is the same shape Stripe, Linear, and Vercel use internally on their own apps. It works because it gives you one answer to "where does this code go?"

Route groups ((marketing), (app), (admin)) handle the URL and layout grouping. Feature folders handle the code-ownership grouping. They solve different problems and you need both.

Server vs client: enforce the boundary, do not document it

In the App Router, every component is a Server Component by default. "use client" opts a leaf and its descendants into the client bundle. The mistake teams make is treating that as a convention rather than a boundary they actively enforce.

Two npm packages plus one ESLint rule fix this for good:

// src/features/billing/server/charge.ts
import "server-only"
import { db } from "@/lib/db"

export async function chargeCustomer(id: string) {
  return db.transaction(async (tx) => { /* ... */ })
}
// src/features/billing/client/CardForm.tsx
"use client"
import "client-only"
// imports from /server/ are now a build error

Add an eslint-plugin-import rule with no-restricted-paths that blocks any file under src/features/*/client/ from importing anything under src/features/*/server/. The boundary becomes a build-time guarantee, not a code-review hope. If you skip this, you will eventually ship a database query in a client bundle. Ask any engineer who has been on call.

One data-access layer, one Drizzle client

The single biggest scale fix in any Next.js codebase: one Drizzle client, instantiated once, reused everywhere.

// src/lib/db.ts
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import { env } from "@/lib/env"
import * as schema from "@/features/_schema"

const globalForDb = globalThis as unknown as {
  client?: ReturnType<typeof postgres>
}

const client =
  globalForDb.client ?? postgres(env.DATABASE_URL, { max: 1 })
if (env.NODE_ENV !== "production") globalForDb.client = client

export const db = drizzle(client, { schema })

The globalThis trick survives Next.js hot module reloading in dev (otherwise you spawn a new pool every save). The max: 1 is the right default for serverless: you want a single connection per Lambda or Edge instance, with PgBouncer or Supabase pooling in front.

Then every feature exposes its queries through a typed module:

// src/features/billing/data/queries.ts
import "server-only"
import { db } from "@/lib/db"
import { subscriptions } from "@/features/billing/schema"

export async function getSubscription(userId: string) {
  return db.query.subscriptions.findFirst({
    where: (s, { eq }) => eq(s.userId, userId),
  })
}

No component, server action, or route handler talks to Drizzle directly. They go through the feature's data/ module. When you migrate from Postgres to a different store later (or shard, or add a read replica), you change five files instead of fifty.

Env-typed config: fail at build time, not at request time

process.env.STRIPE_SECRET_KEY! is the worst line in your codebase. The ! lies. The runtime check that catches the missing value happens during a real customer's checkout.

Use @t3-oss/env-nextjs with Zod and split build-time from runtime:

// src/lib/env.ts
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
    NODE_ENV: z.enum(["development", "production", "test"]),
  },
  client: {
    NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1),
  },
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
    NODE_ENV: process.env.NODE_ENV,
    NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
  },
})

The build fails if a server var is missing. The bundle excludes server vars from the client. Typos become compile errors. This is the same pattern shipped in the T3 stack and the Vercel deployment templates.

Error boundaries and Suspense streaming

App Router gives you four files that are load-bearing for scale: loading.tsx, error.tsx, not-found.tsx, and global-error.tsx. Most teams ship without them and call it "fine."

It is not fine. A single throw in a server component without an error.tsx boundary takes down the entire route tree and shows a blank screen. Stream what is fast, suspend what is slow:

// app/(app)/dashboard/page.tsx
import { Suspense } from "react"
import { Skeleton } from "@/ui/skeleton"
import { Recent } from "@/features/dashboard"

export default function Page() {
  return (
    <>
      <Header />
      <Suspense fallback={<Skeleton />}>
        <Recent />
      </Suspense>
    </>
  )
}

The header renders immediately from the cache; the slow query streams in. Pair this with loading.tsx for the route shell and error.tsx for failure isolation. You get instant TTFB and a UI that degrades gracefully on partial failure. This pattern is what makes Vercel's own dashboard feel fast even when one panel is querying a slow analytics warehouse. The same approach pairs naturally with server actions in Next.js 15 for mutations that should optimistically update UI.

Monorepo patterns: Turborepo and workspaces

The moment you ship a second deployable (a mobile app, a marketing site, an admin panel, an internal Slack bot) that needs to share types or UI, switch to a monorepo. The 2026 default is Turborepo with pnpm workspaces:

ConcernDefault Next.jsScale-ready setup
Where features liveapp/ folderssrc/features/<domain>
DB clientnew Drizzle per routesingleton via globalThis
Env varsprocess.env strings@t3-oss/env-nextjs Zod schema
Server/clientimplicitserver-only + ESLint enforced
Shared UIcopy-pastepackages/ui workspace
Monoreposingle appTurborepo + pnpm workspaces
Test runnernoneVitest + Playwright in CI

Layout:

apps/
  web/          # Next.js 15
  marketing/    # Next.js 15, separate deploy
  admin/        # Next.js 15, internal
packages/
  ui/           # Shared components, Tailwind preset
  db/           # Drizzle schema + migrations
  config/       # tsconfig, ESLint, Tailwind base
  i18n/         # next-intl dictionaries
turbo.json
pnpm-workspace.yaml

turbo.json defines the pipeline: build depends on ^build, lint runs in parallel, test caches by file hash. CI runs turbo run build lint test and only rebuilds what changed. This is the same setup Vercel uses on vercel/next.js itself. For internationalization, next-intl reads dictionaries from packages/i18n and works with the App Router's [locale] segment for SSG of every locale.

If you are coming from an older app, the move to this layout pairs naturally with a Pages-to-App-Router migration; do them in the same epic, not two.

CI testing structure that scales

Tests live in three places:

  1. Unit tests (Vitest) co-located with the feature they test: src/features/billing/server/charge.test.ts. Fast, run on every commit, no DB.
  2. Integration tests in tests/integration/ against a real Postgres in a Docker container, seeded per test. Run on PR.
  3. End-to-end (Playwright) in apps/web/e2e/. Run nightly and on staging deploy.

GitHub Actions runs them as a matrix: lint and unit on every push (under 60 seconds), integration on PR (under 5 minutes), e2e on merge to main. Same machine pattern Vercel and Linear use.

Storybook fits next to packages/ui and gives designers a route-free way to review components. Most teams skip it until the component library passes 30 components and the cost of context-switching becomes real.

Conventions that survive onboarding

A 50-engineer codebase decays without enforced conventions. The cheapest enforcement layer is one ARCHITECTURE.md at the repo root that answers three questions: where does new code go, what are the feature-module boundaries, and how do I run tests. Pair it with a CODEOWNERS file that maps src/features/billing/ to the billing team's GitHub group.

Route groups become team boundaries: app/(billing-team)/, app/(growth-team)/. Each team owns its group's layout, its features under src/features/<their-domain>/, and its CODEOWNERS entry. PRs auto-route for review.

This matters because most onboarding lag comes from "where do I put this?" friction, not from learning the framework. On Cadence we measure a 27-hour median time-to-first-commit for engineers joining new codebases, and the single biggest predictor of that number is whether the repo has a one-page architecture doc and a clear feature-folder convention. Without it the median doubles.

If you are auditing your own structure, our stack auditor gives an honest grade on conventions, dependency hygiene, and the seams that tend to crack first at scale.

When you can skip all of this

Best practices have ROI curves. If you are a solo founder, pre-revenue, on five routes and one deployable, the entire monorepo conversation is overhead you should not pay. Ship a flat App Router project, put queries in lib/db.ts, validate env with Zod, and call it done.

The break-even points where the playbook above starts paying for itself:

  • More than ~5 engineers committing to the same repo.
  • More than ~20 routes.
  • A second deployable that wants to share code.
  • A real on-call rotation (because error.tsx and server-only save you at 3am).

Below those thresholds, the scaffolding is friction. Above them, the lack of scaffolding is friction. Pick the side of the line you are actually on.

Common pitfalls

A handful of patterns look right and break in production:

  • Importing from app/ into features. Routes import features. Features never import from app/. If you reverse it, you couple domain logic to URLs.
  • One giant lib/utils.ts. Anything over 200 lines becomes a junk drawer. Split by purpose: lib/db.ts, lib/env.ts, lib/format.ts.
  • Passing the Drizzle client as a prop. Just import the singleton. Dependency injection in Next.js is almost always overkill.
  • Forgetting error.tsx on the dashboard route. A single thrown error blanks the whole tree. The symptom is "the app went white for 8 seconds during a database hiccup."
  • Mixing build-time and runtime config. NEXT_PUBLIC_* is bundled and frozen at build time. If you change it in Vercel and don't redeploy, nothing happens.
  • Treating a components/ folder as a feature folder. It is not. Components inside components/ should be domain-agnostic. The moment a <BillingCard> lives there, the convention is dead.

The Cadence connection

Most senior engineers can hold this entire architecture in their head. The hard part is enforcing it on a team for 18 months while shipping features. That is a Senior tier job ($1,500/week on Cadence) or a Lead ($2,000/week) if the work also includes the monorepo migration and team-mapping. Every engineer on Cadence is AI-native by default, vetted on Cursor, Claude Code, and Copilot fluency before they unlock bookings, which matters here because most of the conventions above (ESLint rules, Zod schemas, Turborepo configs) are exactly the mechanical work AI-fluent engineers compress from days to hours. Related: if file-size limits or upload paths show up in your scope, our take on file uploads in Next.js covers the same architectural seams.

If you are mid-restructure and want a senior pair on it, Cadence shortlists vetted engineers in 2 minutes with a 48-hour free trial. You can book a senior engineer for a one-week structural pass and see whether the conventions stick before you commit to anything monthly.

FAQ

Should I use the src/ folder?

Yes once you cross 20 files at the root. It separates application code from configuration files (next.config.js, tsconfig.json, package.json) and is now the default in the Vercel templates. Below that threshold the extra path segment is noise.

Where do I put shared UI components in a monorepo?

In packages/ui as a workspace. Export a barrel index, consume via the workspace alias (@workspace/ui), and share Tailwind config in packages/config/tailwind. Storybook lives next to packages/ui and reads from the same source.

Do I need feature folders if I have route groups?

Yes. They solve different problems. Route groups solve URL grouping and layout sharing. Feature folders solve code ownership and the "where does this go?" question. A growing codebase needs both.

How do I prevent client code from importing server code?

Two layers. First, the server-only and client-only npm packages throw at build time if a wrong-side import sneaks through. Second, an ESLint no-restricted-paths rule that blocks src/features/*/client/** from importing src/features/*/server/**. Belt and suspenders.

When should I switch to a monorepo?

When you have a second deployable artifact (mobile, marketing, admin, Slack bot) that needs to share types or UI with the Next.js app. Until then, a single repo with src/features/ is enough. The monorepo cost (Turborepo, workspaces, CI cache config) is real and not worth paying preemptively.

All posts