EDUCATIONOverviewStudentsTeachersSchoolsCurriculumProject Library
GUIDED PROJECT·Advanced·~4 weeks·15 min read

Build a SaaS Starter

Build the skeleton of a real software business: authentication, subscription billing with Stripe, and multi-tenant data so each customer's workspace is isolated. This is the architecture under most modern SaaS products — the project that proves you can build something someone would pay for.

WHAT YOU'LL WALK AWAY WITH
  • A deployable SaaS skeleton: users sign up, subscribe, and land in their own workspace
  • Subscription billing wired end to end with Stripe Checkout and webhooks
  • Multi-tenancy done right — data scoped to an organization, not just a single user
  • The webhook pattern: how your app stays in sync with an external system that owns the source of truth
TIER
Advanced
TIME
~4 weeks
SKILLS
Auth, billing, multi-tenancy, deployment
STACK
Next.js, Postgres, Stripe
PORTFOLIO
Very high — a launchable business skeleton
REQUIRES
Expense Tracker or equivalent

Almost every SaaS company you've heard of runs on the same handful of parts: users sign up, they belong to a team or workspace, that workspace pays a subscription, and their data is walled off from everyone else's. Individually none of these is exotic. The engineering that matters is how they fit together — and getting that architecture right is what separates a launchable product from a pile of features that leak data or lose track of who's paying.

This is the project where you build that skeleton for real. It assumes you've finished the Expense Tracker or something equivalent — you're comfortable with authentication and a typed database layer, because this build is those foundations taken one level up. The honest scope note: you are not building a finished product with a polished UI and every edge case covered. You're building the architecture — the org model, the billing sync, the isolation boundary — that a real product is assembled on top of. That's the hard part, and it's the part worth understanding.

Info

Estimated time: ~4 weeks, part-time. Not because there's an enormous amount of code, but because two of these milestones — multi-tenancy and webhooks — are concepts that reward slow, deliberate work. Rush them and you get a demo that appears to work until a second customer signs up or a subscription silently falls out of sync. Build them properly and you understand how the industry actually does this.

What you're building

A working SaaS skeleton. A visitor signs up, which creates both a user and the organization they own. They land in a protected app shell — routes nobody can reach without being signed in. From there they can start a subscription through Stripe Checkout, and their plan state updates in your database the moment Stripe confirms the payment. Every piece of data they create is scoped to their organization, invisible to every other tenant.

Stack: Next.js (App Router) for the UI and server routes in one project, Postgres for the data, and Stripe for billing. Why this trio: Next.js keeps your protected routes and your webhook handler in the same codebase; Postgres gives you the relational integrity multi-tenancy depends on (foreign keys from data to organizations); and Stripe is the near-universal default for subscription billing, so what you learn here transfers to real jobs. As with the Expense Tracker, the specific tools matter less than understanding what each layer is responsible for.

Milestone 0 — Architecture: tenants vs. users vs. subscriptions

Before installing anything, draw the model. Three entities and the lines between them decide everything downstream. On paper:

organizations                 users
  id          uuid, pk          id        uuid, pk
  name        text              email     text
  stripe_customer_id  text      ...

memberships                   subscriptions
  id       uuid, pk             id                    uuid, pk
  org_id   uuid → organizations org_id                uuid → organizations
  user_id  uuid → users         stripe_subscription_id text
  role     text (owner|member)   status                text (active|canceled|…)
                                 plan                   text

Sit with why it's shaped this way, because each choice is one a senior engineer makes on instinct:

  • The organization is the tenant, not the user. In the Expense Tracker, data was scoped to a user_id. Here it's scoped one level up — to an org_id — because real SaaS is bought by teams, and a workspace outlives any single member. This is the single most important line in the whole build.
  • A membership joins users to organizations. A user can belong to more than one org, and an org has many users, so the relationship is many-to-many — which is exactly what a join table (memberships) is for. The role column is where "who can change billing" will eventually live.
  • The subscription belongs to the organization, not the user. The team pays, so entitlements — which plan is active, whether it's paid up — hang off org_id. And notice stripe_customer_id and stripe_subscription_id: your database keeps only pointers to Stripe's records. Stripe owns the billing truth; you store references to it.
Heads up

This milestone is pure design and it's the highest-leverage one in the project. If you scope data to user_id instead of org_id, you'll build the entire app on the wrong foundation and discover it only when a customer adds a teammate who can't see the team's data. Get the tenant boundary right on paper first. If you can explain why the subscription hangs off the org and not the user, you already understand this project better than most people who've shipped a version of it.

Milestone 1 — Auth and organizations (a user belongs to an org)

Scaffold the app and stand up authentication, then make sign-up do the thing that defines this build: create an organization and a membership alongside the user.

npx create-next-app@latest saas-starter --typescript --app
cd saas-starter
npm install drizzle-orm postgres stripe
npm install -D drizzle-kit

Reuse the auth approach from the Expense Tracker (a session you can read on the server). The new part is what happens after a user is created — provision their tenant in the same transaction:

// On successful sign-up, inside one transaction:
await db.transaction(async (tx) => {
  const [org] = await tx
    .insert(organizations)
    .values({ name: `${user.email}'s workspace` })
    .returning();

  await tx.insert(memberships).values({
    orgId: org.id,
    userId: user.id,
    role: "owner",
  });
});

What to understand: a new user is useless without a tenant to belong to, so the two are created together, atomically. The transaction matters — if the membership insert failed after the org was created, you'd have an orphaned organization and a user who belongs to nothing. db.transaction guarantees both rows land or neither does. From now on, "the current org" is something you look up from the signed-in user's membership on every request — never something the browser tells you.

Milestone 2 — The protected app shell (auth-gated routes)

Now build the walls. Everything under /app/(dashboard) should be unreachable without a session. In the App Router, a layout is the natural gate — it runs on the server before any child page renders.

// app/(dashboard)/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession();
  if (!session) redirect("/login");

  // The session tells you who the user is; look up their org from it.
  return <section>{children}</section>;
}

What to understand: the gate lives on the server, before the protected UI is ever sent to the browser. A common beginner mistake is hiding pages with client-side JavaScript — checking if (!loggedIn) return null inside a React component. That's not security; the code and data still shipped to the browser, and anyone can see them in dev tools. A server-side redirect means an unauthenticated request never receives the protected content at all. This is the same principle as the Expense Tracker's "authorization happens on the server" rule, applied to whole routes instead of individual queries.

Milestone 3 — Stripe Checkout: turning a signup into a subscription

Time to take money. You will not build a payment form — handling card details yourself is a compliance nightmare you should never take on. Instead you hand the user off to a Stripe-hosted Checkout page and get them back when it's done. Create a server route that starts a Checkout session for the current org:

// app/api/checkout/route.ts
import Stripe from "stripe";
import { getCurrentOrg } from "@/lib/org";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST() {
  const org = await getCurrentOrg(); // from the session, never the request body

  const checkout = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer: org.stripeCustomerId ?? undefined,
    line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
    success_url: `${process.env.APP_URL}/dashboard?checkout=success`,
    cancel_url: `${process.env.APP_URL}/pricing`,
    client_reference_id: org.id, // so the webhook knows which org paid
    metadata: { orgId: org.id },
  });

  return Response.json({ url: checkout.url });
}

The browser calls this route and redirects the user to checkout.url. Stripe collects the card, charges it, and sends the user to your success_url.

What to understand: notice what this route does not do — it never marks the org as subscribed. The success redirect is just a friendly landing page; the user could close the tab before reaching it, or hit the URL directly without paying. You cannot trust the redirect. The only trustworthy signal that a subscription started comes from Stripe itself, out of band — which is the entire subject of the next milestone. Carry client_reference_id and metadata.orgId through so that signal can be tied back to the right tenant.

Milestone 4 — Webhooks: keeping your database in sync with Stripe's source of truth

This is the conceptual heart of the build. Stripe is now the source of truth for billing. Your app finds out what Stripe knows through webhooks: Stripe makes an HTTP request to a route you expose whenever something billing-related happens — a subscription starts, renews, fails to pay, or cancels. Your job is to verify that request is genuinely from Stripe, then update your database to match.

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text(); // raw body — needed to verify the signature
  const signature = req.headers.get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  // Idempotency: if we've already processed this event id, stop.
  const seen = await db.query.processedEvents.findFirst({
    where: eq(processedEvents.id, event.id),
  });
  if (seen) return new Response("ok"); // already handled

  if (event.type === "customer.subscription.updated") {
    const sub = event.data.object as Stripe.Subscription;
    await db
      .update(subscriptions)
      .set({ status: sub.status, plan: sub.items.data[0].price.id })
      .where(eq(subscriptions.stripeSubscriptionId, sub.id));
  }

  await db.insert(processedEvents).values({ id: event.id });
  return new Response("ok");
}

What to understand: two ideas do all the work here.

  • Signature verification. Anyone on the internet can POST to your webhook URL. constructEvent checks a cryptographic signature (computed with your webhook secret) proving the request really came from Stripe. Without it, an attacker could forge a "subscription active" event and get a free plan. The raw request body is required — parsing it to JSON first breaks the signature check.
  • Idempotency. Stripe guarantees at-least-once delivery, which means the same event can arrive twice (retries, network hiccups). If your handler isn't idempotent, a duplicate subscription.updated might double-count something, or a duplicate charge event might grant two months of access. Recording each processed event.id and skipping ones you've already seen makes reprocessing a no-op.
Heads up

The rule that governs this entire project: never trust the client for entitlements. Whether an org is on a paid plan comes from Stripe, delivered through a signature-verified webhook, and stored in your database — never from anything the browser sends. A button that says "I'm a Pro user, unlock this" is a suggestion, not a fact; you check the plan server-side against the row Stripe's webhook wrote. And because those webhooks can be delivered more than once, every handler must be idempotent. Get either of these wrong and you have a billing system that leaks access or corrupts state — the two failures a paying customer notices immediately.

Milestone 5 — Multi-tenant data isolation (scope every query to the organization)

Now the payoff of Milestone 0. Whatever your app's actual feature is — projects, documents, invoices — every row carries an org_id, and every query filters by the current org. This is the Expense Tracker's per-user rule, moved up a level to per-organization.

// The current org comes from the session's membership, never the request.
const org = await getCurrentOrg();

const rows = await db
  .select()
  .from(projects)
  .where(eq(projects.orgId, org.id)); // the isolation boundary, on every read

// And on every write, stamp the org from the server, never the body:
await db.insert(projects).values({ ...input, orgId: org.id });

What to understand: the isolation boundary is only as strong as its least careful query. One route that forgets the where org_id clause leaks one tenant's data to another — the single most damaging bug a SaaS product can ship. Because that's so easy to get wrong by hand, production apps lean on row-level security: rules enforced by Postgres itself, so even a query that forgets the filter can't return another org's rows. Enforce the filter in your queries now, and read how row-level security makes the database the last line of defense — it's an hour that will change how you think about multi-tenant data.

Tip

Here's the test that proves it works: sign up as two different users, create data in each, and confirm neither can see the other's — not in the UI, and not by hitting the API routes directly. If you can't leak tenant A's data into tenant B's session no matter how you poke at it, your isolation boundary holds. This is also a natural checkpoint to run Cartara against your repo (see the last section).

Milestone 6 — Ship it (env vars, migrations, deploy)

A SaaS skeleton isn't real until it's deployed with live billing wired up. Deploy to a host that runs Node (Vercel is zero-config for Next.js):

  1. Push the repo to GitHub and import it into your host.
  2. Set every secret as an environment variable in the host's dashboardDATABASE_URL, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_ID, APP_URL — never committed to the repo.
  3. Run your migrations against the production database, so the tables exist before the first request.
  4. In the Stripe dashboard, register your webhook endpoint (https://your-app.com/api/webhooks/stripe) and subscribe it to the subscription events. Stripe gives you the signing secret here — that's the STRIPE_WEBHOOK_SECRET from step 2.

Then do a real end-to-end run: sign up, subscribe with a Stripe test card, and watch the webhook update your database. Use the Stripe CLI to forward events to your local machine while developing (stripe listen --forward-to localhost:3000/api/webhooks/stripe) so you're not deploying just to test a handler. When a test payment flows all the way from Checkout to a status: active row in your Postgres, you've built the real thing.

Stuck? Study a production build

When a piece won't click, reading real code beats another tutorial — but read these after you've built your own version, so you're comparing your reasoning to theirs instead of copying an answer:

  • nextjs/saas-starter — an official Next.js + Stripe template. Study how it wires auth, teams, and billing; you'll recognize every piece you built by hand.
  • calcom/cal.com — a large production open-source SaaS. See how a real product handles teams, billing, and multi-tenancy at scale.
  • stripe-samples/checkout-single-subscription — Stripe's own minimal subscription example, the reference for the Checkout and webhook flow in Milestones 3 and 4.

The order matters: build first, read second. A finished implementation shows you what the code is; struggling with the problem first is what teaches you why it's shaped that way.

Build it with Cartara

Here's the honest part. You could get this entire skeleton running by pasting each block above and never really grasping why the subscription hangs off the org, why the webhook has to verify its signature, or why a single forgotten where org_id is a data breach. It would run. You'd also be unable to change it, debug a billing-sync failure, or answer an interviewer's "walk me through how you kept your database in sync with Stripe" — and with an AI assistant writing large parts of a build this size, that gap between what shipped and what you understand can open up without you noticing.

Closing that gap is exactly what Cartara is for. As you build:

  • Point Cartara at your repo. It watches the code that lands — including anything an AI assistant generated — and turns each meaningful change into a checkpoint for understanding, not just a commit.
  • Explain your changes back. When you add the webhook handler or the org-scoping filter, Cartara asks you the why and the trade-offs — the same questions a code reviewer or interviewer would.
  • See your comprehension, not just your output. You get an honest read on which parts of your own system you actually understand — the tenancy model, the billing sync, the isolation boundary — so you can shore up the weak spots before they surface in a review or an outage.

That's the loop this whole library is built around: build something real, then prove to yourself you understand it. The SaaS skeleton is the project. The understanding is the point.

Turn a launchable SaaS skeleton into understanding you can prove

Point Cartara at your saas-starter repo and it captures what you genuinely learned across auth, billing, and multi-tenancy — the honest way to make an ambitious, AI-assisted build truly yours.

Join the waitlist

Reference implementations

Production code worth studying — afteryou've built your own version.

nextjs/saas-starter
An official Next.js + Stripe SaaS template. Study its billing and auth wiring after you've built your own — you'll recognize every piece.
calcom/cal.com
A large production open-source SaaS. See how a real product handles teams, billing, and multi-tenancy at scale.
stripe-samples/checkout-single-subscription
Stripe's own minimal subscription example — the reference for the Checkout + webhook flow in Milestones 3–4.
← All projects

Build it with Cartara

Point Cartara at your repo as you build this project. It captures what you actually learned from every change — including the code an AI assistant wrote — so an AI-assisted build becomes understanding you can prove.

Join the waitlist