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.tsSplitting (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.