SaaS Starter
A complete SaaS starter template with user authentication, Stripe subscriptions, team management, and admin dashboard.
Features
Section titled “Features”- User authentication with sessions
- Email/password and OAuth login
- Stripe subscription integration
- Team/organization management
- Role-based access control
- Admin dashboard
- Settings pages
Project Structure
Section titled “Project Structure”Directoryapp/
- page.tsx # Landing page
- layout.tsx # Root layout
Directory(marketing)/
Directorypricing/
- page.tsx
Directoryfeatures/
- page.tsx
Directory(auth)/
Directorylogin/
- page.tsx
Directoryregister/
- page.tsx
Directoryforgot-password/
- page.tsx
Directoryreset-password/
- page.tsx
Directory(dashboard)/
- layout.tsx # Dashboard layout
- middleware.ts # Auth required
Directorydashboard/
- page.tsx # Dashboard home
Directoryprojects/
- page.tsx
Directorynew/
- page.tsx
Directory[id]/
- page.tsx
Directoryteam/
- page.tsx
Directoryinvite/
- page.tsx
Directorysettings/
- page.tsx
Directorybilling/
- page.tsx
Directoryprofile/
- page.tsx
Directoryapi/
Directorystripe/
Directorywebhook/
- route.ts
Directorycheckout/
- route.ts
Directoryportal/
- route.ts
Database Schema
Section titled “Database Schema”-- UsersCREATE TABLE users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT, name TEXT NOT NULL, avatar_url TEXT, email_verified BOOLEAN DEFAULT FALSE, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')));
-- TeamsCREATE TABLE teams ( id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, stripe_customer_id TEXT, stripe_subscription_id TEXT, plan TEXT DEFAULT 'free', created_at TEXT DEFAULT (datetime('now')));
-- Team membersCREATE TABLE team_members ( team_id TEXT NOT NULL REFERENCES teams(id), user_id TEXT NOT NULL REFERENCES users(id), role TEXT NOT NULL DEFAULT 'member', created_at TEXT DEFAULT (datetime('now')), PRIMARY KEY (team_id, user_id));
-- Team invitationsCREATE TABLE invitations ( id TEXT PRIMARY KEY, team_id TEXT NOT NULL REFERENCES teams(id), email TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', token TEXT UNIQUE NOT NULL, expires_at TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')));Authentication
Section titled “Authentication”Login Page
Section titled “Login Page”// app/(auth)/login/page.tsximport type { PageProps, LoaderArgs } from '@cloudwerk/core';import { RedirectError } from '@cloudwerk/core';
export async function loader({ context, request }: LoaderArgs) { const user = await context.auth.getUser(); if (user) { throw new RedirectError('/dashboard'); }
const url = new URL(request.url); const error = url.searchParams.get('error');
return { error };}
export default function LoginPage({ error }: PageProps & { error: string | null }) { return ( <div className="auth-container"> <h1>Sign In</h1>
{error && <div className="error">{error}</div>}
<form action="/api/auth/login" method="POST"> <div> <label htmlFor="email">Email</label> <input type="email" id="email" name="email" required /> </div> <div> <label htmlFor="password">Password</label> <input type="password" id="password" name="password" required /> </div> <button type="submit">Sign In</button> </form>
<div className="divider">or continue with</div>
<div className="oauth-buttons"> <a href="/api/auth/github" className="btn-oauth">GitHub</a> <a href="/api/auth/google" className="btn-oauth">Google</a> </div>
<p> Don't have an account? <a href="/register">Sign up</a> </p> <p> <a href="/forgot-password">Forgot password?</a> </p> </div> );}Stripe Integration
Section titled “Stripe Integration”Checkout Session
Section titled “Checkout Session”// app/api/stripe/checkout/route.tsimport { json, redirect } from '@cloudwerk/core';import Stripe from 'stripe';
export async function POST(request: Request, { context }: CloudwerkHandlerContext) { const user = await context.auth.requireUser(); const { priceId } = await request.json();
const stripe = new Stripe(context.env.STRIPE_SECRET_KEY);
// Get or create Stripe customer let customerId = user.team.stripe_customer_id;
if (!customerId) { const customer = await stripe.customers.create({ email: user.email, metadata: { teamId: user.team.id }, }); customerId = customer.id;
await context.db .updateTable('teams') .set({ stripe_customer_id: customerId }) .where('id', '=', user.team.id) .execute(); }
const session = await stripe.checkout.sessions.create({ customer: customerId, mode: 'subscription', line_items: [{ price: priceId, quantity: 1 }], success_url: `${context.env.APP_URL}/settings/billing?success=true`, cancel_url: `${context.env.APP_URL}/settings/billing?canceled=true`, });
return json({ url: session.url });}Webhook Handler
Section titled “Webhook Handler”// app/api/stripe/webhook/route.tsimport { json } from '@cloudwerk/core';import Stripe from 'stripe';
export async function POST(request: Request, { context }: CloudwerkHandlerContext) { const stripe = new Stripe(context.env.STRIPE_SECRET_KEY); const signature = request.headers.get('stripe-signature')!;
let event: Stripe.Event;
try { event = stripe.webhooks.constructEvent( await request.text(), signature, context.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { return json({ error: 'Invalid signature' }, { status: 400 }); }
switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as Stripe.Checkout.Session; await handleCheckoutCompleted(session, context); break; }
case 'customer.subscription.updated': { const subscription = event.data.object as Stripe.Subscription; await handleSubscriptionUpdated(subscription, context); break; }
case 'customer.subscription.deleted': { const subscription = event.data.object as Stripe.Subscription; await handleSubscriptionDeleted(subscription, context); break; } }
return json({ received: true });}
async function handleCheckoutCompleted(session: Stripe.Checkout.Session, context: CloudwerkContext) { const customerId = session.customer as string; const subscriptionId = session.subscription as string;
await context.db .updateTable('teams') .set({ stripe_subscription_id: subscriptionId, plan: 'pro', }) .where('stripe_customer_id', '=', customerId) .execute();}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription, context: CloudwerkContext) { const plan = subscription.status === 'active' ? 'pro' : 'free';
await context.db .updateTable('teams') .set({ plan }) .where('stripe_subscription_id', '=', subscription.id) .execute();}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription, context: CloudwerkContext) { await context.db .updateTable('teams') .set({ stripe_subscription_id: null, plan: 'free', }) .where('stripe_subscription_id', '=', subscription.id) .execute();}Team Management
Section titled “Team Management”Team Invitation
Section titled “Team Invitation”// app/api/team/invite/route.tsimport { json } from '@cloudwerk/core';
export async function POST(request: Request, { context }: CloudwerkHandlerContext) { const user = await context.auth.requireUser();
// Check if user is admin const membership = await context.db .selectFrom('team_members') .where('team_id', '=', user.team.id) .where('user_id', '=', user.id) .executeTakeFirst();
if (membership?.role !== 'admin') { return json({ error: 'Not authorized' }, { status: 403 }); }
const { email, role } = await request.json(); const token = crypto.randomUUID();
await context.db .insertInto('invitations') .values({ id: crypto.randomUUID(), team_id: user.team.id, email, role, token, expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), }) .execute();
// Send invitation email await context.queues.EMAIL_QUEUE.send({ type: 'team_invitation', to: email, data: { teamName: user.team.name, inviterName: user.name, inviteUrl: `${context.env.APP_URL}/invite/${token}`, }, });
return json({ success: true });}Pricing Page
Section titled “Pricing Page”// app/(marketing)/pricing/page.tsximport type { PageProps } from '@cloudwerk/core';
const plans = [ { name: 'Free', price: 0, features: ['Up to 3 projects', '1 team member', 'Community support'], priceId: null, }, { name: 'Pro', price: 29, features: ['Unlimited projects', 'Up to 10 team members', 'Priority support', 'Advanced analytics'], priceId: 'price_pro_monthly', }, { name: 'Enterprise', price: 99, features: ['Everything in Pro', 'Unlimited team members', 'SSO', 'Dedicated support', 'SLA'], priceId: 'price_enterprise_monthly', },];
export default function PricingPage() { return ( <div> <h1>Simple, Transparent Pricing</h1> <p>Choose the plan that works for you</p>
<div className="pricing-grid"> {plans.map((plan) => ( <div key={plan.name} className="pricing-card"> <h2>{plan.name}</h2> <div className="price"> ${plan.price}<span>/month</span> </div> <ul> {plan.features.map((feature) => ( <li key={feature}>{feature}</li> ))} </ul> {plan.priceId ? ( <a href={`/register?plan=${plan.priceId}`} className="btn-primary"> Get Started </a> ) : ( <a href="/register" className="btn-secondary"> Start Free </a> )} </div> ))} </div> </div> );}Configuration
Section titled “Configuration”Environment Variables
Section titled “Environment Variables”# wrangler.toml[vars]APP_URL = "https://myapp.com"
# Set via wrangler secret# STRIPE_SECRET_KEY# STRIPE_WEBHOOK_SECRET# STRIPE_PRICE_PRO# STRIPE_PRICE_ENTERPRISENext Steps
Section titled “Next Steps”- Add email verification flow
- Implement password reset
- Add audit logging
- Implement usage-based billing
- Add team analytics dashboard
Related Examples
Section titled “Related Examples”- Blog Example - Content management
- API Backend - RESTful API design