Build a SaaS App

Add authentication, subscription billing, and background jobs to a Roost application.

Note

What you'll learn

  • Protecting routes with WorkOS authentication via AuthMiddleware
  • Gating premium features with SubscribedMiddleware from @roostjs/billing
  • Creating Stripe Checkout sessions and verifying webhooks
  • Dispatching background jobs with @roostjs/queue

Time: ~45 minutes

Prerequisites:

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 install

You 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_...
Tip

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 dev

Visit 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 cookie

You 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 Workspace

This 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 migrate

You 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' },
    });
  },
});
Warning

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 SendWelcomeEmail

Fill 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 dev
stripe listen --forward-to localhost:3000/billing/webhook

Then walk through the complete signup-to-subscription flow:

  1. Visit http://localhost:3000/auth/login and sign in with WorkOS. You should land on /dashboard.
  2. Click Subscribe now. You should be redirected to Stripe Checkout.
  3. Complete checkout with test card 4242 4242 4242 4242. You should be redirected to /billing/success.
  4. In terminal 2, you should see Stripe deliver a customer.subscription.created event and receive a 200 response.
  5. In terminal 1 (wrangler output), you should see the SendWelcomeEmail job log line appear as the queue consumer processes the message.
  6. Visit http://localhost:3000/features/premium. You should now see the premium page instead of being redirected to checkout.
Tip

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:

Next Steps