Tatvex — AI-first development studio
Web DevSaaS

A Next.js + shadcn/ui SaaS Boilerplate Walkthrough

3 min read

Every SaaS MVP needs the same unglamorous plumbing before the actual product idea gets to shine: auth, organizations, billing, and a dashboard shell. Here's the structure I reach for by default, and why.

Folder structure

src/
  app/
    (marketing)/          # public pages — layout without app chrome
    (app)/
      dashboard/           # authenticated product surface
      settings/
      api/
        webhooks/stripe/
  components/
    ui/                    # shadcn/ui primitives
    dashboard/
  lib/
    auth.ts
    db.ts
    stripe.ts

Splitting (marketing) and (app) into separate route groups early avoids the common mistake of the marketing site slowly absorbing dashboard-only dependencies and layout logic.

Multi-tenancy: organization-first, not user-first

The data model decision that's hardest to walk back later is whether resources belong to a user or an organization. Default to organization-scoped from day one, even for a single-user MVP — retrofitting teams later means a painful migration.

model Organization {
  id        String   @id @default(cuid())
  name      String
  members   Membership[]
  projects  Project[]
}
 
model Membership {
  id             String       @id @default(cuid())
  userId         String
  organizationId String
  role           Role         @default(MEMBER)
  organization   Organization @relation(fields: [organizationId], references: [id])
}

Every query in the app layer should filter by organizationId from the authenticated session — never trust a client-supplied org ID for authorization.

Billing: webhook-driven, not client-driven

Stripe subscription state should live in your database, kept in sync via webhooks — not read live from Stripe on every request.

// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
  const event = stripe.webhooks.constructEvent(
    await req.text(),
    req.headers.get("stripe-signature")!,
    process.env.STRIPE_WEBHOOK_SECRET!
  );
 
  switch (event.type) {
    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      const sub = event.data.object;
      await db.organization.update({
        where: { stripeCustomerId: sub.customer as string },
        data: { plan: mapPriceToPlan(sub.items.data[0].price.id), status: sub.status },
      });
      break;
    }
  }
 
  return new Response(null, { status: 200 });
}

Gate features by reading organization.plan from your own database — this stays fast and correct even if Stripe has a bad day.

The dashboard shell

shadcn/ui's Sheet, Tabs, and a simple sidebar composed from Card primitives gets you a credible dashboard shell in under a day, without reaching for a heavier admin template. Resist adding a full design system before you know which screens the product actually needs — three real screens teach you more about the layout than a generic dashboard kit does.

What to build first

Ship the core workflow — the one thing a user does that proves the product's value — before touching settings pages, team invites, or usage analytics. Those matter, but only after someone has used the core feature and told you it's worth paying for.

Structure and patterns shown here are the ones I default to — swap the ORM, auth provider, or billing details for what fits your specific stack.

T

The Tatvex Founder

Founder, Tatvex — building AI/ML, web, and SaaS products.

Available for new projects

Solving something similar?

Let's talk about your project.