Build a SaaS App
Add authentication, subscription billing, and background jobs to a Roost application.
What you'll learn
- Protecting routes with WorkOS authentication via
AuthMiddleware - Gating premium features with
SubscribedMiddlewarefrom@roostjs/billing - Creating Stripe Checkout sessions and verifying webhooks
- Dispatching background jobs with
@roostjs/queue
Time: ~45 minutes
Prerequisites:
- Completed the Quick Start
- A WorkOS account (free tier works)
- A Stripe account (test mode works)
Packages used: @roostjs/auth, @roostjs/billing, @roostjs/orm, @roostjs/start, @roostjs/queue
Step 1: Create the Project
Use the --with-billing flag to scaffold a project that includes
Stripe billing wiring, the @roostjs/queue package, and the necessary
Cloudflare Queue bindings in wrangler.jsonc.
roost new saas-app --with-billing
cd saas-app
bun installYou should see: A new saas-app/ directory. Running ls src/ shows routes/, models/, and a
pre-wired config/billing.ts alongside the standard scaffolding.
Step 2: Configure WorkOS Credentials
Open .dev.vars and add your WorkOS credentials. You can find these
in the WorkOS dashboard under API Keys and your application's Client ID.
WORKOS_API_KEY=sk_test_...
WORKOS_CLIENT_ID=client_...The .dev.vars file is in .gitignore by default.
Never commit it. For production, set these secrets in the Cloudflare dashboard
or with wrangler secret put WORKOS_API_KEY.
You should see: No visible change yet — credentials are loaded at runtime when the dev server starts.
Step 3: Start the Dev Server and Verify Auth Routes
bun run devVisit the following URLs in your browser. WorkOS handles the full OAuth flow;
these routes are registered automatically by AuthServiceProvider
once your credentials are in .dev.vars.
/auth/login # Redirects to WorkOS hosted login
/auth/callback # Receives the OAuth code, creates a session
/auth/logout # Clears the session cookieYou should see: Visiting /auth/login redirects you to WorkOS. After signing in, you land back at /dashboard (which doesn't exist yet — a 404 is expected at this point).
Step 4: Create the Workspace Model
SaaS apps typically tie billing and data ownership to an organization (workspace).
Generate a Workspace model:
roost make:model WorkspaceThis creates src/models/workspace.ts with a stub. Open it and replace the
placeholder columns with the schema your SaaS app needs. Each workspace belongs
to a WorkOS organization:
import { Model } from '@roostjs/orm';
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const workspaces = sqliteTable('workspaces', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
organization_id: text('organization_id').notNull().unique(),
stripe_customer_id: text('stripe_customer_id'),
subscription_id: text('subscription_id'),
subscription_status: text('subscription_status').notNull().default('inactive'),
created_at: text('created_at'),
updated_at: text('updated_at'),
});
export class Workspace extends Model {
static tableName = 'workspaces';
static _table = workspaces;
}Run the migration:
roost migrateYou should see: Output from drizzle-kit push confirming the workspaces table was created. The model file at src/models/workspace.ts is ready to use.
Step 5: Create a Protected Dashboard Route
AuthMiddleware is a Roost middleware registered on the Application instance.
Register it globally in your app entry point so it runs on every request before
route handlers execute:
import { Application } from '@roostjs/core';
import { AuthServiceProvider, AuthMiddleware } from '@roostjs/auth';
import { OrmServiceProvider } from '@roostjs/orm';
export function createApp(env: Record<string, unknown>) {
return Application.create(env)
.register(OrmServiceProvider)
.register(AuthServiceProvider)
.useMiddleware(new AuthMiddleware());
}Then create the dashboard route — it renders only for authenticated users because
AuthMiddleware redirects to /auth/login before the handler runs:
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/dashboard')({
component: DashboardPage,
});
function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<p>You're signed in. Subscribe to unlock premium features.</p>
<a href="/billing/checkout">Subscribe now</a>
</div>
);
}You should see: Visiting /dashboard while signed out redirects you to /auth/login. After signing in, the dashboard renders.
Step 6: Add SubscribedMiddleware to Gate Premium Features
SubscribedMiddleware from @roostjs/billing redirects to /billing/checkout
when the current user's workspace does not have an active subscription. Add it
after AuthMiddleware in the pipeline so auth is checked first:
import { Application } from '@roostjs/core';
import { AuthServiceProvider, AuthMiddleware } from '@roostjs/auth';
import { BillingServiceProvider, SubscribedMiddleware } from '@roostjs/billing';
import { OrmServiceProvider } from '@roostjs/orm';
export function createApp(env: Record<string, unknown>) {
return Application.create(env)
.register(OrmServiceProvider)
.register(AuthServiceProvider)
.register(BillingServiceProvider)
.useMiddleware(new AuthMiddleware())
.useMiddleware(new SubscribedMiddleware());
}Create the premium route — it renders only for subscribed users:
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/features/premium')({
component: PremiumPage,
});
function PremiumPage() {
return (
<div>
<h1>Premium Features</h1>
<p>You have an active subscription. Welcome to the good stuff.</p>
</div>
);
}You should see: Visiting /features/premium while signed in but without an active subscription redirects you to /billing/checkout. You'll create that route in the next step.
Step 7: Configure Stripe and Create the Checkout Route
Add your Stripe credentials to .dev.vars:
WORKOS_API_KEY=sk_test_...
WORKOS_CLIENT_ID=client_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID=price_...Now create the checkout route. It uses StripeProvider.createCheckoutSession
to generate a hosted Stripe Checkout URL and redirects the user there.
import { createFileRoute, redirect } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import { AuthMiddleware } from '@roostjs/auth';
import { StripeProvider } from '@roostjs/billing';
const startCheckout = createServerFn({ method: 'GET' }).handler(async ({ context }) => {
const env = context.env as { STRIPE_SECRET_KEY: string; STRIPE_WEBHOOK_SECRET: string; STRIPE_PRICE_ID: string };
const billing = new StripeProvider(env.STRIPE_SECRET_KEY, env.STRIPE_WEBHOOK_SECRET);
// In a real app, load the workspace's stripe_customer_id from the database.
// For this tutorial we create a new customer each time.
const customer = await billing.createCustomer({
name: 'Tutorial User',
email: 'user@example.com',
});
const session = await billing.createCheckoutSession({
customerId: customer.providerId,
priceId: env.STRIPE_PRICE_ID,
successUrl: 'http://localhost:3000/billing/success',
cancelUrl: 'http://localhost:3000/dashboard',
});
throw redirect({ href: session.url });
});
export const Route = createFileRoute('/billing/checkout')({
middleware: [new AuthMiddleware()],
loader: () => startCheckout(),
component: () => null,
});You should see: Visiting /billing/checkout while signed in immediately redirects you to Stripe's hosted checkout page. Use Stripe's test card 4242 4242 4242 4242 with any future expiry and CVC.
Step 8: Handle the subscription.created Webhook
After a successful checkout, Stripe sends a customer.subscription.created
event to your webhook endpoint. verifyStripeWebhook validates the
signature before you process the event.
import { createServerFileRoute } from '@tanstack/react-start/server';
import { verifyStripeWebhook, WebhookVerificationError } from '@roostjs/billing';
import { Workspace } from '../../models/workspace';
import { SendWelcomeEmail } from '../../jobs/send-welcome-email';
export const ServerRoute = createServerFileRoute('/billing/webhook').methods({
POST: async ({ request, context }) => {
const env = context.env as { STRIPE_WEBHOOK_SECRET: string };
let event;
try {
event = await verifyStripeWebhook(request, env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
if (err instanceof WebhookVerificationError) {
return new Response('Invalid signature', { status: 400 });
}
throw err;
}
if (event.type === 'customer.subscription.created') {
const subscription = event.data.object as {
id: string;
status: string;
customer: string;
metadata: Record<string, string>;
};
const workspace = await Workspace.where('stripe_customer_id', subscription.customer).first();
if (workspace) {
workspace.attributes.subscription_id = subscription.id;
workspace.attributes.subscription_status = subscription.status;
await workspace.save();
}
const organizationId = subscription.metadata['organization_id'] ?? '';
await SendWelcomeEmail.dispatch({ organizationId });
}
return new Response(JSON.stringify({ received: true }), {
headers: { 'Content-Type': 'application/json' },
});
},
});Always return a 200 response to Stripe quickly. If processing takes
more than a few seconds, dispatch a job immediately and do the work in the
background — exactly what the next step covers.
You should see: When forwarding a test webhook with the Stripe CLI (stripe listen --forward-to localhost:3000/billing/webhook), the terminal shows a 200 response and your database row is updated.
Step 9: Create the SendWelcomeEmail Job
Generate a job class for sending the welcome email. The @Queue
decorator tells the dispatcher which Cloudflare Queue binding to use.
roost make:job SendWelcomeEmailFill in the generated file:
import { Job, Queue } from '@roostjs/queue';
interface Payload {
organizationId: string;
}
@Queue('default')
export class SendWelcomeEmail extends Job<Payload> {
async handle(): Promise<void> {
const { organizationId } = this.payload;
// Replace with your actual email-sending logic.
// @roostjs/ai, an HTTP client to Resend, or any transactional
// email service can go here.
console.log(`Sending welcome email for organization: ${organizationId}`);
}
onFailure(error: Error): void {
console.error(`SendWelcomeEmail failed for ${this.payload.organizationId}:`, error);
}
}You should see: The file at src/jobs/send-welcome-email.ts is in place. TypeScript will error if handle() is missing — the abstract method contract is enforced at compile time.
Step 10: Dispatch the Job from the Webhook Handler
The webhook handler in Step 8 already calls SendWelcomeEmail.dispatch.
The static dispatch method serializes the payload and sends it to the
Cloudflare Queue bound to the 'default' queue name. No additional
wiring is needed.
If you want to delay the email by 30 seconds (for example, to let the database
write settle across replicas), use dispatchAfter:
// Dispatch immediately
await SendWelcomeEmail.dispatch({ organizationId });
// Or delay by 30 seconds
await SendWelcomeEmail.dispatchAfter(30, { organizationId });You should see: In wrangler dev output, a log line confirming the message was enqueued. The consumer worker picks it up and runs handle(), printing the console log from your job class.
Step 11: Test the Full Flow
Run the dev server and the Stripe CLI webhook forwarder in two terminals:
bun run devstripe listen --forward-to localhost:3000/billing/webhookThen walk through the complete signup-to-subscription flow:
- Visit
http://localhost:3000/auth/loginand sign in with WorkOS. You should land on/dashboard. - Click Subscribe now. You should be redirected to Stripe Checkout.
- Complete checkout with test card
4242 4242 4242 4242. You should be redirected to/billing/success. - In terminal 2, you should see Stripe deliver a
customer.subscription.createdevent and receive a200response. - In terminal 1 (wrangler output), you should see the
SendWelcomeEmailjob log line appear as the queue consumer processes the message. - Visit
http://localhost:3000/features/premium. You should now see the premium page instead of being redirected to checkout.
Use stripe trigger customer.subscription.created in a third
terminal to re-fire the event without going through checkout again during
development.
What You Built
In this tutorial you:
- Scaffolded a project with billing support using
roost new --with-billing - Configured WorkOS credentials and verified the three auth routes (
/auth/login,/auth/callback,/auth/logout) - Created a
Workspacemodel that tracks Stripe customer and subscription IDs - Protected a dashboard route with
AuthMiddleware - Gated a premium route with
SubscribedMiddleware - Created a Stripe Checkout session with
StripeProvider.createCheckoutSession - Verified and handled the
customer.subscription.createdwebhook usingverifyStripeWebhook - Defined a
SendWelcomeEmailjob by extendingJoband decorating it with@Queue('default') - Dispatched the job from a webhook handler with the static
dispatchmethod
Next Steps
- @roostjs/auth reference — full API for
SessionManager,OrgResolver, and role-based middleware - @roostjs/billing guide — subscription swaps, usage-based billing, the customer portal, and webhook event catalogue
- Auth concepts — how WorkOS AuthKit, the session layer, and organization membership fit together in Roost