Back to Blog
·11 min
BI

BreafIO Team

Product & Engineering

Clean Architecture Next.js Boilerplate: A No-Bloat Guide

Introduction

Clean architecture is not just a buzzword — it is the difference between a SaaS application that scales gracefully and one that becomes a maintenance nightmare after six months. In the Next.js ecosystem, many boilerplates prioritize speed of setup over code organization, leading to bloat, tangled dependencies, and difficulty making changes without breaking unrelated features. In this guide, we show you how to build a clean architecture Next.js SaaS boilerplate without the bloat, with practical folder structure patterns, dependency management strategies, and modular design principles.

Why Clean Architecture Matters for SaaS

SaaS applications grow quickly. What starts as a simple auth + billing flow becomes a complex system with teams, roles, API keys, webhooks, analytics, and integrations. Without clean architecture, adding a new feature requires touching 5+ files across the codebase, understanding implicit dependencies, and praying you don't break the auth flow. Clean architecture solves this by enforcing clear boundaries between layers: the UI layer does not know about the database, the business logic layer does not know about the framework, and the data layer does not know about either. This separation means you can change the database without rewriting the UI, or swap the auth provider without touching billing logic.

The Recommended Folder Structure

The foundation of clean architecture in Next.js is a well-organized folder structure. Here is the pattern we recommend:

src/

app/ — Next.js App Router pages and API routes (thin controllers)

lib/

auth/ — Authentication logic (NextAuth config, middleware)

billing/ — Stripe integration (webhooks, checkout, portal)

db/ — Prisma schema and client

email/ — Resend email templates

api-keys/ — API key management and rate limiting

errors/ — Custom error classes

components/

ui/ — Reusable UI primitives (Button, Input, Card)

features/ — Feature-specific components (AuthForm, PricingTable)

layouts/ — Page layouts (DashboardLayout, MarketingLayout)

types/ — Shared TypeScript types and interfaces

The key principle is that each folder under src/lib/ is a self-contained module. The auth module does not import from billing, the billing module does not import from api-keys. They communicate only through well-defined interfaces.

Module Isolation with Dependency Injection

The secret to preventing bloat is module isolation. Each module (auth, billing, api-keys, etc.) should export a public API that other modules can consume, but keep its internal implementation private. In TypeScript, you achieve this with barrel exports and explicit access patterns:

// lib/billing/index.ts — public API
export { createCheckoutSession } from './checkout'
export { handleWebhook } from './webhooks'
export { getCustomerPortalUrl } from './portal'

// lib/billing/internal.ts — private implementation
export function validateWebhookSignature() { ... }

When another module needs billing functionality, it imports from './billing', never from './billing/internal'. This enforces clean boundaries and makes it impossible to accidentally couple modules together.

The Thin Controller Pattern in Next.js

Next.js App Router pages should be thin controllers: they handle the HTTP request, call the appropriate module function, and return the response. They should not contain business logic. Here is the pattern:

// app/api/billing/checkout/route.ts
import { createCheckoutSession } from '@/lib/billing'

export async function POST(req: Request) {
  const { priceId } = await req.json()
  const session = await createCheckoutSession(priceId)
  return Response.json({ url: session.url })
}

This is 5 lines of actual code. All the logic — Stripe API calls, error handling, database updates — lives in the billing module. The controller is just an HTTP adapter.

Prisma as the Data Layer

Prisma is the ideal ORM for clean architecture because it enforces a clear separation between your data models and your application logic. Your Prisma schema is the single source of truth for your data structure. The Prisma client is generated from the schema and provides type-safe database access. Business logic never directly queries the database — it goes through a repository or service layer that wraps Prisma calls.

Error Handling Without Framework Coupling

Clean architecture means your business logic does not know about Next.js. This includes error handling. Define custom error classes in your lib/errors/ module:

export class BillingError extends Error {
  constructor(message: string, public code: string) {
    super(message)
    this.name = 'BillingError'
  }
}

Your billing module throws BillingError. Your API route catches it and maps it to an HTTP response. This separation means you can test your billing logic without Next.js, and swap the HTTP framework without changing billing code.

Measuring Bloat

A useful metric for bloat is the number of cross-module imports. If your billing module imports from auth, api-keys, email, and db, you have high coupling. If it only imports from db (for data access) and types (for type definitions), you have clean architecture. Aim for each module to import from at most 2-3 other modules.

Building Without Bloat

The BreafIO Core SaaS Boilerplate follows this clean architecture pattern. Auth, billing, teams, API keys, and email are separate modules with clear boundaries. The total bundle size is minimal because you are not shipping unused features — each module is tree-shakeable and independently removable. If you are building your own SaaS boilerplate from scratch, follow the folder structure, module isolation, and thin controller patterns described here. The upfront investment in clean architecture pays for itself within the first two months of active development.

Ready to Build?

Get started with our production-ready starter kits and ship your project faster.

Browse Starter Kits