DEV Community

Syed Muhammad Ali
Syed Muhammad Ali

Posted on • Originally published at devstacked.tech

SaaS Starter Architecture with Next.js 2026

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
Enter fullscreen mode Exit fullscreen mode

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 useMemo or useCallback when 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!)
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

💡 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).*)"],
};
Enter fullscreen mode Exit fullscreen mode

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 matcher config. 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;
};
Enter fullscreen mode Exit fullscreen mode

💡 Why a central types/ folder? If you define User in 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);
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

💡 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
Enter fullscreen mode Exit fullscreen mode

⚠️ Critical: Add .env.local to your .gitignore immediately. 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

  1. Add Authentication with Clerk
  2. Add Stripe Billing
  3. Add Database with Supabase
  4. Add Transactional Emails with Resend

Useful Resources

Top comments (0)