Engineering

How to Structure a Modern SaaS Codebase

Jan 28, 2026
8 min read
E.A
Emmanuel Asika

Stop building spaghetti code. Here is a battle-tested, feature-first directory structure for Next.js, Supabase, and Shadcn that scales from MVP to IPO.

You know that feeling when you open a project you haven't touched in six months and you actually want to cry? I've been there. Back when I was churning out WordPress sites for clients, the structure didn't matter as much. You had your themes, your plugins, and a functions.php file that eventually turned into a graveyard of spaghetti code. It worked, but it wasn't engineering.

Now that I'm deep into Cloud Engineering and building SaaS products with Next.js and Supabase, I realize that structure isn't just about being neat. It is about survival.

When you are an Indie Hacker or a solo dev, you don't have a QA team. You don't have a dedicated architect. You have you. If your codebase fights you, you lose momentum. And in this game, momentum is everything.

So, how do you structure a modern SaaS codebase to ship fast without creating a dumpster fire? Here is the blueprint I am using right now.

The Philosophy: Feature-First, Not Type-First

Most tutorials tell you to organize files by their type. They tell you to put all your controllers in one folder, all your views in another, and all your utils in a third. This is the MVC hangover talking.

In a modern React/Next.js context, this sucks.

When you are building a SaaS, you are building features. You are building an Auth system, a Subscription flow, a Dashboard, a Settings panel. When you need to fix a bug in the billing system, you do not want to open components/Button.tsx and pages/billing.tsx and lib/utils.ts and api/stripe.js. You want everything related to billing to be near each other.

I advocate for Colocation. Keep things that change together, close together.

The High-Level Directory Structure

Let's assume we are using the src directory (always use src, it keeps the root clean). Here is the tree I default to for a scalable SaaS:

. ├── src │ ├── app # Next.js App Router (Routes ONLY) │ ├── components # Shared UI (Shadcn, atomic components) │ ├── config # Environment vars, constants, navigation config │ ├── db # Database schemas, migrations (Drizzle/Prisma) │ ├── features # THE MEAT. Business logic grouped by domain │ ├── hooks # Global hooks (use-toast, etc) │ ├── lib # Utils, library wrappers (Supabase, Stripe, OpenAI) │ ├── styles # Global CSS │ ├── types # Global TS types │ └── middleware.ts

This looks standard until you look inside features and app. That is where the magic happens.

The features Directory: Domain-Driven Design Lite

This is the most controversial but valuable part of my stack. I don't dump everything into components. I create a features folder.

If my SaaS has Authentication, a Dashboard, and Invoicing, my structure looks like this:

src/features ├── auth │ ├── components # Auth-specific UI (LoginForm, SignupForm) │ ├── hooks # Auth-specific hooks │ ├── actions.ts # Server Actions for Auth │ └── schema.ts # Zod schemas for Auth forms ├── invoicing │ ├── components # InvoiceList, InvoiceCard │ ├── utils # invoice-calculator.ts │ ├── actions.ts # createInvoice, deleteInvoice │ └── types.ts # Invoice-specific interfaces

Why this wins:

  1. Cognitive Load: When I am working on invoicing, I only care about the invoicing folder. The rest of the app doesn't exist to me.
  2. Deletability: If I pivot and decide to kill the invoicing feature, I delete the invoicing folder. I don't have to hunt down orphaned files in five different directories.
  3. Scalability: This structure works for a solo dev and it works for a team of twenty.

The app Directory: strictly Routing

Since Next.js 13+, the App Router has changed the game. But people abuse it. They put complex logic inside page.tsx.

Do not do this.

Your page.tsx should be dumb. It should fetch data (since it's a Server Component) and pass it to a feature component. That's it.

Here is what a typical page file looks like in my codebase:

// src/app/(dashboard)/invoices/page.tsx import { Suspense } from "react"; import { InvoiceList } from "@/features/invoicing/components/invoice-list"; import { InvoiceSkeleton } from "@/features/invoicing/components/skeleton"; import { getInvoices } from "@/features/invoicing/queries"; export default async function InvoicesPage() { // Fetch data directly in the server component const initialData = await getInvoices(); return ( <div className="space-y-6"> <h1 className="text-3xl font-bold">Invoices</h1> <Suspense fallback={<InvoiceSkeleton />}> <InvoiceList initialData={initialData} /> </Suspense> </div> ); }

See how clean that is? The logic for how the list renders is in the feature folder. The data fetching query is imported. The page just orchestrates.

The Data Layer: Supabase & Server Actions

I'm betting big on Supabase. It fits the "Indie Hacker" ethos perfectly-Postgres but easier. But you need to organize your database interactions or you will create security holes.

I don't use API routes (pages/api) anymore unless I am building webhooks for Stripe or Lemon Squeezy. For everything else, I use Server Actions.

But here is the rule: Server Actions should not hold business logic.

Server Actions are entry points. They are like controllers in the old MVC world. They should validate input, check auth, and then call a "Service" function.

The Service Pattern

Create a clear separation between the HTTP layer (Server Actions) and the Logic layer.

Bad: Writing 50 lines of Supabase logic inside actions.ts.

Good:

// src/features/invoicing/actions.ts "use server"; import { z } from "zod"; import { createInvoiceService } from "./service"; import { invoiceSchema } from "./schema"; import { getUser } from "@/lib/auth"; import { revalidatePath } from "next/cache"; export async function createInvoiceAction(formData: FormData) { const user = await getUser(); if (!user) throw new Error("Unauthorized"); // 1. Validate Input const rawData = Object.fromEntries(formData); const result = invoiceSchema.safeParse(rawData); if (!result.success) { return { error: "Invalid data" }; } // 2. Call Service try { await createInvoiceService(user.id, result.data); revalidatePath("/invoices"); return { success: true }; } catch (e) { return { error: "Failed to create invoice" }; } }

And then the service handles the actual DB interaction:

// src/features/invoicing/service.ts import { createClient } from "@/lib/supabase/server"; export async function createInvoiceService(userId: string, data: any) { const supabase = createClient(); const { error } = await supabase .from("invoices") .insert({ ...data, user_id: userId }); if (error) throw error; }

Why split this? Because now I can reuse createInvoiceService in a seed script, a cron job, or a webhook, without hacking around the FormData requirement of the Server Action.

UI Components: The Shadcn/Radix Workflow

If you aren't using Shadcn UI yet, start now. It gives you ownership of your code. It's not a library you install in node_modules; it copies code into your components/ui folder.

I keep my components folder strictly for generic UI.

src/components ├── ui # Shadcn components (Button, Input, Sheet) ├── layout # Header, Sidebar, Footer ├── shared # Loaders, generic ErrorStates

Crucial tip: Do not modify the Shadcn components unless absolutely necessary. If you need a specific variation of a button for the Dashboard, build a wrapper component in features/dashboard/components. Keep the base UI primitive and pure.

Type Safety is Non-Negotiable

Coming from dynamic WordPress PHP, TypeScript felt like a straightjacket at first. Now I refuse to code without it.

With Supabase, you get types for free. I set up a script in package.json to generate types automatically:

"scripts": { "db:types": "supabase gen types typescript --project-id my-project-id > src/types/supabase.ts" }

Then, in my lib/supabase/client.ts, I inject these types:

import { createBrowserClient } from '@supabase/ssr' import { Database } from '@/types/supabase' export function createClient() { return createBrowserClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ) }

This means when I type supabase.from('...'), VS Code autocompletes my table names. It knows that the status column can only be 'pending' or 'paid'. This catches 80% of bugs before I even run the app.

Environment Variables and Config

Hardcoding strings is a rookie mistake. I see it all the time. "Oh, I'll just paste the Stripe Public Key here for now." No. You won't change it later. You will forget.

I use a config object to centralize everything. I usually put this in src/config/site.ts or src/env.mjs (using T3 env for validation).

// src/config/routes.ts export const ROUTES = { AUTH: { LOGIN: "/login", REGISTER: "/register", }, DASHBOARD: { HOME: "/dashboard", BILLING: "/dashboard/billing", } } as const;

Use this everywhere. href={ROUTES.AUTH.LOGIN}. If you decide to change the login URL to /signin later, you change it in one place.

The "Lib" Folder: Your Toolkit

This is the junk drawer, but an organized one. This is where I initialize third-party libraries.

  • lib/utils.ts: The classic Shadcn class merger (cn).
  • lib/stripe.ts: Stripe initialization.
  • lib/openai.ts: AI client setup.

Don't put business logic here. If you are writing a function called calculateMRR, that belongs in features/billing/utils.ts, not lib/utils.ts. Keep lib for generic wrappers and helpers.

Handling State (You probably don't need Redux)

In the WordPress world, state was usually "submit form, reload page." In React, we tend to over-engineer state.

For a standard SaaS, the URL is the best state manager.

If a user is filtering a list of invoices, don't put that filter in a useState or a global store. Put it in the URL query params: /dashboard/invoices?status=paid&sort=desc.

Why?

  1. It's shareable. The user can copy the link and send it to a co-founder.
  2. It survives a refresh.
  3. Server Components can read the URL params directly and fetch the correct data without client-side waterfalls.

I only reach for global state (Zustand) when I have complex interactive UI that persists across pages, like a music player or a complex multi-step wizard.

Testing: Don't Go Crazy (Yet)

Look, we are building to ship. I am not going to sit here and tell you to aim for 100% test coverage. That is a waste of time for a startup.

However, write integration tests for the critical paths.

If your billing logic fails, you lose money. If your auth fails, you lose users. Write a simple Playwright test for the signup flow and the checkout flow. For unit tests, I stick to complex helpers (like that MRR calculator). I don't test React components.

Conclusion: Constraints are Freedom

It feels contradictory, but adding these strict rules to my codebase has given me more freedom.

When I open my IDE, I don't have to make decisions about where to put a file. I know where it goes.

When I need to debug something, I know exactly where to look.

This structure bridges the gap between the "Just ship it" mentality of Indie Hacking and the rigorous engineering standards I'm learning in my Masters. It is robust enough to handle scale, but flexible enough to let you iterate quickly.

Stop fighting your codebase. Organize it, treat it with respect, and it will let you build things you didn't think were possible.

Now, go ship something.

#how#IndieHacker

Read Next