After building multiple SaaS products, this is the file structure that scales from MVP to production.
The Structure
src/
├── app/
│ ├── (app)/ # Authenticated (shared sidebar layout)
│ │ ├── layout.tsx
│ │ ├── dashboard/page.tsx
│ │ ├── settings/page.tsx
│ │ └── billing/page.tsx
│ ├── (auth)/ # Auth (centered minimal layout)
│ │ ├── login/page.tsx
│ │ └── register/page.tsx
│ ├── (marketing)/ # Public (marketing nav/footer)
│ │ ├── pricing/page.tsx
│ │ └── blog/page.tsx
│ ├── api/
│ │ ├── auth/[...nextauth]/ # Auth handler
│ │ ├── stripe/ # Billing webhooks
│ │ └── ai/chat/ # AI endpoint
│ ├── layout.tsx # Root layout
│ └── page.tsx # Landing page
├── components/
│ ├── ui/ # Button, Input, Card, Badge
│ ├── layout/ # Nav, Sidebar, Footer
│ └── ai/ # Chat interface
├── lib/
│ ├── auth.ts # Auth config
│ ├── db.ts # Prisma singleton
│ ├── stripe.ts # Stripe + plans
│ └── utils.ts # cn(), formatDate()
├── prisma/
│ └── schema.prisma
└── types/
└── next-auth.d.ts
Why route groups
Parenthesized folders (app), (auth), (marketing) share layouts without affecting URLs:
-
(app)→ sidebar layout -
(auth)→ centered layout -
(marketing)→ marketing nav - URLs stay clean:
/dashboardnot/app/dashboard
Why lib/ has exactly 4 files
- auth.ts — single source of truth for auth
- db.ts — Prisma singleton (prevents connection exhaustion on hot reload)
- stripe.ts — client + plan definitions
- utils.ts — cn(), formatDate(), absoluteUrl()
Why components/ui/ stays small
Button, Input, Card, Badge. Each has variants via props. Covers 90% of dashboard UI. Add more only when needed.
What NOT to add early
- middleware.ts (add when you need route-level checks)
- i18n/ (add when you need multiple languages)
- hooks/ (most are premature abstractions)
Get this pre-built
This exact structure with everything wired up is LaunchKit ($49).
Top comments (0)