Skip to main content
Back to Blog
Architecture

How I Structure a Next.js Project (After 6 Production Apps)

November 8, 202511 min read
Next.jsReactTypeScriptProject StructureArchitecture

How I Structure a Next.js Project (After 6 Production Apps)

I've shipped 6 Next.js apps to production — from simple portfolios to a fintech platform with 185 tables. My project structure has evolved with each one. Here's where I've landed.

The Structure

``` project/ ├── app/ # Next.js App Router │ ├── (marketing)/ # Route groups for layout sharing │ │ ├── page.tsx # Landing page │ │ ├── about/ │ │ └── pricing/ │ ├── (dashboard)/ # Authenticated layout │ │ ├── layout.tsx # Shared sidebar, auth check │ │ ├── page.tsx # Dashboard home │ │ ├── settings/ │ │ └── billing/ │ ├── api/ # API routes │ │ ├── webhooks/ # Stripe, GitHub webhooks │ │ └── v1/ # Versioned API │ └── layout.tsx # Root layout (fonts, metadata) ├── components/ │ ├── ui/ # Primitives (Button, Input, Card) │ ├── features/ # Feature-specific (PricingTable, TradeCard) │ └── layout/ # Nav, Footer, Sidebar ├── lib/ # Shared utilities │ ├── db.ts # Database client │ ├── auth.ts # Auth helpers │ ├── stripe.ts # Stripe client │ └── utils.ts # General utilities ├── data/ # Static data, constants └── types/ # Shared TypeScript types ```

The Rules

Rule 1: Route groups for layout separation.

`(marketing)` pages get a public layout (nav + footer). `(dashboard)` pages get an authenticated layout (sidebar + auth check). The parentheses mean the group name doesn't appear in the URL.

Rule 2: Components go in 3 buckets, no more.

`ui/` is for pure, reusable components with no business logic. They take props and render. Think shadcn/ui.

`features/` is for components tied to a specific feature. `PricingTable` knows about subscription tiers. `TradeCard` knows about positions. They import from `lib/` and have business logic.

`layout/` is for the shell — Navigation, Footer, Sidebar, MobileMenu. Max 5-6 files.

Rule 3: Server components by default, client only when needed.

If a component doesn't need interactivity, it's a server component. No `'use client'` unless it uses useState, useEffect, onClick, or a browser API. This reduces your JavaScript bundle dramatically.

Rule 4: API routes are thin.

```typescript // app/api/v1/strategies/route.ts import { createStrategy, getStrategies } from '@/lib/strategies'; import { validateAuth } from '@/lib/auth';

export async function GET(req: Request) { const user = await validateAuth(req); if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 }); return Response.json(await getStrategies(user.id)); }

export async function POST(req: Request) { const user = await validateAuth(req); if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 }); const body = await req.json(); return Response.json(await createStrategy(user.id, body)); } ```

The route validates and delegates. Business logic lives in `lib/`.

Rule 5: Types live separately.

```typescript // types/strategy.ts export interface Strategy { id: string; name: string; symbol: string; timeframe: '1m' | '5m' | '15m' | '1h' | '4h' | '1d'; status: 'active' | 'paused' | 'archived'; createdAt: Date; } ```

Types are imported everywhere — components, API routes, lib functions. Keeping them in one place prevents the "I defined Strategy in 3 different files" problem.

What I Got Wrong Initially

Mistake: Putting everything in `components/`. By table #80, I had 120 components in a flat directory. Finding anything took 30 seconds of scrolling. The `ui/` + `features/` + `layout/` split solved this.

Mistake: Fat API routes. My early routes had database queries, validation, error handling, and response formatting all inline. When I needed the same logic in a webhook handler, I had to duplicate it. Moving logic to `lib/` made it reusable.

Mistake: Not using route groups from the start. I had `app/layout.tsx` trying to conditionally render a sidebar or navigation based on the pathname. Route groups eliminate this entirely — each group gets its own layout.

The File I Wish Existed

Every Next.js project needs a `lib/config.ts` that centralizes environment variables:

```typescript // lib/config.ts function requireEnv(key: string): string { const value = process.env[key]; if (!value) throw new Error(`Missing required env: ${key}`); return value; }

export const config = { database: { url: requireEnv('DATABASE_URL'), }, stripe: { secretKey: requireEnv('STRIPE_SECRET_KEY'), webhookSecret: requireEnv('STRIPE_WEBHOOK_SECRET'), }, supabase: { url: requireEnv('NEXT_PUBLIC_SUPABASE_URL'), anonKey: requireEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY'), }, } as const; ```

This crashes at startup if any required variable is missing. Better to crash immediately than to get a mysterious `undefined` error at 3am when a Stripe webhook fires.

Want to see this in action?

Check out the projects and case studies behind these articles.