You've got the idea. You're pumped. You open your terminal, run npx create-next-app, and then... you stare at a blank page.tsx wondering — where do I even begin?
This is the silent killer of SaaS projects. Not the idea. Not the tech. The architecture. Most developers start hacking features together and three weeks in, the codebase is a mess of spaghetti — auth logic mixed with UI, API calls scattered everywhere, no clear structure. It's painful to extend and even more painful to hand off.
The good news? A solid SaaS architecture isn't complicated. It's just a set of decisions made upfront — decisions about folders, responsibilities, and boundaries. Get those right and building features becomes fast, predictable, and even enjoyable.
By the end of this guide, you'll have a clear, production-ready SaaS folder structure with authentication, billing, protected routes, and a dashboard layout — all wired together with Next.js App Router, TypeScript, Tailwind CSS, and React Compiler.
Why Most SaaS Projects Fall Apart Early
Here's the honest truth: most developers (even experienced ones) start with a feature-first mindset. They add auth, then billing, then a dashboard — each piece bolted on without a plan. It works for a while. Then it doesn't.
The problems that show up:
- You can't tell where business logic lives (is it in the component? the API route? a hook?)
- Protected and public pages share the same layout, causing weird rendering bugs
- Adding a new feature means touching five different files across three folders
- Onboarding a new developer takes days just to explain the structure
The fix isn't a framework or a library. It's deliberate architecture — deciding upfront how your app is organized and what each layer is responsible for.
What We're Building
A scalable SaaS starter with:
- ✅ App Router (Next.js latest)
- ✅ React Compiler (automatic optimization — no manual
useMemo/useCallback) - ✅ TypeScript (strict mode)
- ✅ Tailwind CSS
- ✅ Authentication-ready structure (works with Clerk, Auth0, NextAuth, etc.)
- ✅ Billing-ready structure (works with Stripe)
- ✅ Separate layouts for marketing, auth, and dashboard
- ✅ Proxy-based route protection
Prerequisites
Before we start, make sure you have:
- Node.js 20+ installed
- Basic familiarity with Next.js App Router
- A fresh Next.js project (we'll scaffold one below)
Step 1: Scaffold the Project
Let's create a fresh Next.js app with all the defaults we need.
Run the following command:
npx create-next-app@latest saas-starter
Choose options according to your preferences.
Recommended setup:
- TypeScript
- Tailwind CSS
- App Router
- React Compiler
-
src/directory
React Compiler
React Compiler automatically optimizes your components — it's like having a senior dev go through your entire codebase and add useMemo and useCallback everywhere they're needed. You get that for free.
⚠️ Common Mistake: Don't manually add
useMemooruseCallbackwhen React Compiler is enabled. It'll do it better than you. Adding them manually can actually interfere with the compiler's optimizations.
Step 2: Plan the Folder Structure
This is the most important step. A well-thought-out folder structure acts like a map — anyone who opens the project immediately knows where everything lives.
Here's the structure we're going for:
saas-starter/
├── src/
│ ├── app/
│ │ ├── (marketing)/ # Public-facing pages (landing, pricing, about)
│ │ │ ├── page.tsx # Homepage
│ │ │ ├── pricing/
│ │ │ │ └── page.tsx
│ │ │ └── layout.tsx # Marketing layout (navbar, footer)
│ │ │
│ │ ├── (auth)/ # Auth pages (login, signup, forgot password)
│ │ │ ├── login/
│ │ │ │ └── page.tsx
│ │ │ ├── signup/
│ │ │ │ └── page.tsx
│ │ │ └── layout.tsx # Minimal auth layout (just centered card)
│ │ │
│ │ ├── (dashboard)/ # Protected app pages (only logged-in users)
│ │ │ ├── dashboard/
│ │ │ │ └── page.tsx
│ │ │ ├── settings/
│ │ │ │ └── page.tsx
│ │ │ └── layout.tsx # Dashboard layout (sidebar, topbar)
│ │ │
│ │ ├── api/ # API routes (backend endpoints)
│ │ │ ├── webhooks/
│ │ │ │ └── stripe/
│ │ │ │ └── route.ts
│ │ │ └── user/
│ │ │ └── route.ts
│ │ │
│ │ ├── layout.tsx # Root layout (fonts, global providers)
│ │ └── globals.css
│ │
│ ├── components/
│ │ ├── ui/ # Reusable UI primitives (Button, Input, Modal)
│ │ ├── marketing/ # Components only used on marketing pages
│ │ ├── dashboard/ # Components only used in the dashboard
│ │ └── shared/ # Components used everywhere (Avatar, Logo)
│ │
│ ├── lib/
│ │ ├── auth.ts # Auth helper functions
│ │ ├── stripe.ts # Stripe client setup
│ │ ├── db.ts # Database client setup
│ │ └── utils.ts # General utility functions
│ │
│ ├── hooks/ # Custom React hooks
│ ├── types/ # TypeScript type definitions
│ └── proxy.ts # Route protection logic
└── .env.local # Environment variables (never commit this!)
Let's break down the key decisions here:
Route Groups (marketing), (auth), (dashboard) — The parentheses are a Next.js feature. They let you group routes without affecting the URL. So app/(marketing)/pricing/page.tsx lives at /pricing, not /marketing/pricing. The real power is that each group gets its own layout.tsx — meaning your dashboard gets a sidebar, your auth pages get a clean centered layout, and your marketing pages get a navbar. No mixing.
lib/ folder — This is where your "glue code" lives. Auth helpers, database clients, Stripe setup. These are not components, not hooks — they're plain functions and configurations that any part of the app can import.
types/ folder — All your TypeScript interfaces and types live here. This prevents the chaos of defining type User = {...} in five different files.
Important: In Next.js 16, Middleware has been renamed to Proxy. In older versions, you'll see middleware.ts used instead.
Step 3: Set Up Route Groups & Layouts
Let's create the three layout files that power the different sections of the app.
Root Layout
The root layout wraps everything. It's the right place for global providers (auth context, theme, etc.) and fonts.
// app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: {
template: "%s | YourSaaS",
default: "YourSaaS — The tagline goes here",
},
description: "Your SaaS description here.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
{/* Global providers go here (auth, theme, etc.) */}
{children}
</body>
</html>
);
}
Marketing Layout
// app/(marketing)/layout.tsx
import { Navbar } from "@/components/marketing/Navbar";
import { Footer } from "@/components/marketing/Footer";
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen flex-col">
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}
Auth Layout
// app/(auth)/layout.tsx
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md px-4">{children}</div>
</div>
);
}
Dashboard Layout
// app/(dashboard)/layout.tsx
import { Sidebar } from "@/components/dashboard/Sidebar";
import { Topbar } from "@/components/dashboard/Topbar";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen overflow-hidden bg-gray-100">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Topbar />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
</div>
);
}
💡 Why separate layouts matter: Without this, you'd need conditional logic inside a single layout to decide whether to show the sidebar. That gets messy fast. Route groups keep things clean — each section of your app has its own look, independently.
Step 4: Protect Routes with Proxy
Proxy — think of it as a security guard that stands at the entrance of your app. Every request passes through it before hitting a page. This is the perfect place to check: "Is this user logged in? If not, redirect them to /login."
// proxy.ts (lives at the ROOT of your project, not inside /app)
import { NextRequest, NextResponse } from "next/server";
// These are the routes that require the user to be logged in
const protectedRoutes = ["/dashboard", "/settings", "/billing"];
// These are routes that logged-in users shouldn't see (e.g. login page)
const authRoutes = ["/login", "/signup"];
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check for your auth token/session cookie
// Replace "auth_token" with whatever your auth provider uses
const isAuthenticated = request.cookies.has("auth_token");
// If the user is trying to access a protected route but isn't logged in
if (protectedRoutes.some((route) => pathname.startsWith(route))) {
if (!isAuthenticated) {
return NextResponse.redirect(new URL("/login", request.url));
}
}
// If the user is already logged in and tries to visit /login or /signup
if (authRoutes.includes(pathname) && isAuthenticated) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
}
// Tell Next.js which paths this proxy should run on
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
What's happening here, line by line:
-
protectedRoutes— the list of URL paths only logged-in users can visit -
authRoutes— pages that make no sense for logged-in users (you're already in!) -
request.cookies.has("auth_token")— checks if the auth cookie exists. Your auth provider (Clerk, NextAuth, etc.) will set this automatically -
NextResponse.redirect(...)— sends the user to a different URL -
matcher— tells proxy to skip static files and images (no need to auth-check a.png)
⚠️ Common Mistake: Forgetting the
matcherconfig. Without it, proxy runs on every single request — including CSS files, images, and fonts. That's unnecessary overhead and can cause weird issues.
Related: How to Add Clerk Authentication in Next.js 16
Step 5: Set Up the Types Folder
Define your core types once, use them everywhere. This prevents TypeScript errors and keeps your data shapes consistent.
// types/index.ts
export type User = {
id: string;
email: string;
name: string;
avatarUrl?: string;
plan: "free" | "pro" | "enterprise";
createdAt: Date;
};
export type Subscription = {
id: string;
userId: string;
status: "active" | "cancelled" | "past_due" | "trialing";
plan: "pro" | "enterprise";
currentPeriodEnd: Date;
};
export type ApiResponse<T> = {
data: T | null;
error: string | null;
};
💡 Why a central
types/folder? If you defineUserin five files, changing one field means updating five places. One file, one source of truth.
Step 6: Set Up Core Lib Files
The lib/ folder holds your setup code — the stuff that's not a component but needs to exist before anything works.
Database (example with Supabase)
// lib/db.ts
import { createClient } from "@supabase/supabase-js";
// process.env is how you access environment variables in Next.js
// These are secret keys stored in your .env.local file — never hardcode them!
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export const db = createClient(supabaseUrl, supabaseKey);
Related: Build a Todo App with Next.js 16 and Supabase (2026 Guide)
Stripe Client
// lib/stripe.ts
import Stripe from "stripe";
// The "!" tells TypeScript "trust me, this value exists"
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "latest supported version",
typescript: true,
});
Related: How to Integrate Stripe Checkout with Next.js 16 (2026 Edition)
Utility Functions
// lib/utils.ts
// Combines Tailwind class names without conflicts
// Install clsx and tailwind-merge first: npm install clsx tailwind-merge
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
// Format a date to a readable string
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
month: "long",
day: "numeric",
year: "numeric",
}).format(date);
}
💡 The
cn()function is one of the most used utilities in any Tailwind project. It lets you merge class names conditionally without worrying about conflicting Tailwind classes. Almost every component will use it.
Step 7: Environment Variables
Environment variables are secret keys and configuration values that your app needs but should never be publicly visible. Things like your database password, Stripe secret key, or auth secret.
Create a .env.local file at the root of your project:
# .env.local
# Authentication (example with Clerk)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
# Database (example with Supabase)
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# Stripe
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# App
NEXT_PUBLIC_APP_URL=http://localhost:3000
⚠️ Critical: Add
.env.localto your.gitignoreimmediately. If you push secret keys to GitHub, bad actors can find them and abuse your accounts. This is not hypothetical — it happens all the time.
Variables starting with NEXT_PUBLIC_ are exposed to the browser. Everything else is server-only. Never put secret keys in NEXT_PUBLIC_ variables.
Frequently Asked Questions
Can I use this structure with the Pages Router instead of App Router?
You can, but the route groups (marketing), (auth), and (dashboard) are an App Router feature — they won't work in Pages Router. With Pages Router you'd achieve similar separation using custom _app.tsx logic and manual layout wrappers, but it's messier. App Router is the recommended approach going forward.
Do I have to use Clerk or Supabase specifically?
Not at all. The architecture is auth-provider agnostic. Whether you use Clerk, NextAuth, Auth0, or Supabase Auth — the folder structure stays the same. You just swap out the cookie name in proxy.ts and the client setup in lib/auth.ts.
What if my app grows and the structure doesn't scale?
This structure handles the vast majority of SaaS apps well. If you hit a point where a single components/dashboard/ folder gets too crowded, break it into feature folders — e.g. components/dashboard/billing/, components/dashboard/team/. The core principle stays the same: group by where things are used.
Is React Compiler production-ready?
Yes. As of 2025, React Compiler is stable and recommended for new projects. It's been battle-tested by the Meta team on large-scale apps. Just remember — once it's on, stop writing manual useMemo and useCallback. Let the compiler do its job.
Where should I put shared API logic that multiple routes use?
Put it in lib/. For example, a lib/user.ts file with a getUserById(id) function that your API routes and server components can both import. This avoids duplicating database queries across files.
The Final Picture
Here's what you've got now:
| Layer | What lives here |
|---|---|
app/(marketing) |
Landing page, pricing, about — with navbar & footer |
app/(auth) |
Login, signup — minimal centered layout |
app/(dashboard) |
Protected app pages — sidebar + topbar |
proxy.ts |
Route protection — redirects unauthenticated users |
lib/ |
DB client, Stripe setup, utility functions |
types/ |
Shared TypeScript types — User, Subscription, etc. |
components/ |
UI organized by where it's used |
This architecture scales. Whether you're building alone or with a team of five, adding a new feature means you know exactly which folder it belongs in. There's no guessing, no hunting through files, no spaghetti.
From here, the next steps are wiring in your auth provider of choice, setting up your Stripe webhook handler in app/api/webhooks/stripe/route.ts, and building out your dashboard pages. Each of those is its own focused task — and now you have a solid foundation to do them on.
Happy building. 🚀
Next Steps
- Add Authentication with Clerk
- Add Stripe Billing
- Add Database with Supabase
- Add Transactional Emails with Resend
Top comments (0)