Skip to content

SaaS Starter

A complete SaaS starter template with user authentication, Stripe subscriptions, team management, and admin dashboard.

  • User authentication with sessions
  • Email/password and OAuth login
  • Stripe subscription integration
  • Team/organization management
  • Role-based access control
  • Admin dashboard
  • Settings pages
  • 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
-- Users
CREATE 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'))
);
-- Teams
CREATE 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 members
CREATE 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 invitations
CREATE 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'))
);
// app/(auth)/login/page.tsx
import 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>
);
}
// app/api/stripe/checkout/route.ts
import { 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 });
}
// app/api/stripe/webhook/route.ts
import { 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();
}
// app/api/team/invite/route.ts
import { 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 });
}
// app/(marketing)/pricing/page.tsx
import 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>
);
}
# wrangler.toml
[vars]
APP_URL = "https://myapp.com"
# Set via wrangler secret
# STRIPE_SECRET_KEY
# STRIPE_WEBHOOK_SECRET
# STRIPE_PRICE_PRO
# STRIPE_PRICE_ENTERPRISE
  • Add email verification flow
  • Implement password reset
  • Add audit logging
  • Implement usage-based billing
  • Add team analytics dashboard