
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.
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.
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.
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.
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.
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.
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.
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.
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:
| Concern | Default Next.js | Scale-ready setup |
|---|---|---|
| Where features live | app/ folders | src/features/<domain> |
| DB client | new Drizzle per route | singleton via globalThis |
| Env vars | process.env strings | @t3-oss/env-nextjs Zod schema |
| Server/client | implicit | server-only + ESLint enforced |
| Shared UI | copy-paste | packages/ui workspace |
| Monorepo | single app | Turborepo + pnpm workspaces |
| Test runner | none | Vitest + 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.
Tests live in three places:
src/features/billing/server/charge.test.ts. Fast, run on every commit, no DB.tests/integration/ against a real Postgres in a Docker container, seeded per test. Run on PR.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.
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.
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:
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.
A handful of patterns look right and break in production:
app/ into features. Routes import features. Features never import from app/. If you reverse it, you couple domain logic to URLs.lib/utils.ts. Anything over 200 lines becomes a junk drawer. Split by purpose: lib/db.ts, lib/env.ts, lib/format.ts.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."NEXT_PUBLIC_* is bundled and frozen at build time. If you change it in Vercel and don't redeploy, nothing happens.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.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.
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.
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.
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.
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 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.