ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-04-28Advanced

Build a Complete SaaS in Antigravity — Stripe Billing, Auth, and Deployment Without Leaving Your IDE

Build an entire SaaS product within Antigravity IDE — from Supabase auth to Stripe billing to Cloudflare Workers deployment, without switching tools.

antigravity412saas10stripe9supabase7cloudflare-workers7monetization31fullstack5deployment7

Building a SaaS often means living in a constant state of context switching. You're in your IDE writing code, then switching to the browser to check Stripe's dashboard, then back to the terminal to deploy, then to Supabase to verify the database change. Within an hour, you've lost focus a dozen times, and you're not even sure which tool you should be in right now.

I used to work that way. Then I started using Antigravity for full-stack development, and something shifted. The IDE became everything. I don't leave it to build auth, manage databases, set up billing, or deploy to production. The AI agent writes the code, the terminal runs the tests, and the deployment happens from the same place where I started typing.

The Architecture You're Building

Let's be clear about what the final product looks like:

  • Frontend: Next.js App Router with TypeScript
  • Authentication: Supabase Auth (Email + Google OAuth)
  • Database: PostgreSQL in Supabase with Row-Level Security (RLS)
  • Payments: Stripe Checkout + Webhooks
  • API Layer: Next.js Route Handlers backed by Supabase
  • Deployment: Cloudflare Workers or Vercel

Every single piece of this can be built, tested, and deployed from within Antigravity. No external dashboards required.

Setting Up the Project in Antigravity

Open Antigravity and create a new project. Select the "Next.js with TypeScript" template.

Once the project opens, you'll prompt the AI agent to add dependencies. Here's the exact prompt:

Add these packages to package.json:
- @supabase/supabase-js
- @supabase/auth-helpers-nextjs
- stripe
- clsx

Then run npm install.

The agent installs everything. You don't touch the terminal.

Next, configure environment variables. In the "Environment" tab, add:

NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PRICE_PRO=price_...

All values come from Supabase and Stripe's dashboards (which you only visit once to grab credentials).

Authentication with Supabase

Building auth from scratch is tedious and error-prone. Supabase abstracts away passwords, OTP, session management, and OAuth. You get a production-ready auth system in minutes.

Prompt the agent:

Create lib/supabase.ts with:
- A Supabase client initialized from environment variables
- A getUser() function that returns the current authenticated user
- Handle errors gracefully

The agent generates:

import { createClient } from '@supabase/supabase-js';
 
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
 
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
 
export async function getUser() {
  const { data: { user } } = await supabase.auth.getUser();
  return user;
}
 
export async function signOut() {
  return supabase.auth.signOut();
}

Now create the login page:

Create app/(auth)/login/page.tsx with:
- Email input field
- "Sign in with Email" button
- "Sign in with Google" button (OAuth)
- Link to sign up page
- Error message display
- Loading state

Use Supabase Auth methods. Include full TypeScript types.

The agent produces a working login component:

'use client';
 
import { useState } from 'react';
import { supabase } from '@/lib/supabase';
import { useRouter } from 'next/navigation';
 
export default function LoginPage() {
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
 
  async function handleEmailSignIn(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    setLoading(true);
 
    const { error } = await supabase.auth.signInWithPassword({
      email,
      password,
    });
 
    if (error) {
      setError(error.message);
      setLoading(false);
    } else {
      router.push('/dashboard');
    }
  }
 
  async function handleGoogleSignIn() {
    setError(null);
    setLoading(true);
 
    const { error } = await supabase.auth.signInWithOAuth({
      provider: 'google',
      options: {
        redirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,
      },
    });
 
    if (error) {
      setError(error.message);
      setLoading(false);
    }
  }
 
  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-50">
      <div className="w-full max-w-md p-8 bg-white rounded-lg shadow">
        <h1 className="text-3xl font-bold mb-6 text-center">Sign In</h1>
 
        {error && (
          <div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
            {error}
          </div>
        )}
 
        <form onSubmit={handleEmailSignIn} className="space-y-4 mb-6">
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder="your@email.com"
            required
            className="w-full px-4 py-2 border border-gray-300 rounded-lg"
            disabled={loading}
          />
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder="Password"
            required
            className="w-full px-4 py-2 border border-gray-300 rounded-lg"
            disabled={loading}
          />
          <button
            type="submit"
            disabled={loading}
            className="w-full bg-blue-600 text-white py-2 rounded-lg font-medium disabled:opacity-50"
          >
            {loading ? 'Signing in...' : 'Sign in with Email'}
          </button>
        </form>
 
        <button
          onClick={handleGoogleSignIn}
          disabled={loading}
          className="w-full border border-gray-300 py-2 rounded-lg font-medium disabled:opacity-50"
        >
          Sign in with Google
        </button>
 
        <p className="mt-4 text-center text-sm">
          Don't have an account?{' '}
          <a href="/signup" className="text-blue-600">
            Sign up
          </a>
        </p>
      </div>
    </div>
  );
}

Create the OAuth callback handler:

Create app/auth/callback/route.ts to:
- Handle the OAuth redirect from Supabase
- Extract session from the URL
- Redirect to dashboard on success

Generated code:

import { createClient } from '@supabase/supabase-js';
import { NextRequest, NextResponse } from 'next/server';
 
export async function GET(request: NextRequest) {
  const requestUrl = new URL(request.url);
  const code = requestUrl.searchParams.get('code');
 
  if (code) {
    const supabase = createClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
    );
 
    await supabase.auth.exchangeCodeForSession(code);
  }
 
  // Redirect to dashboard on success, or back to login on failure
  return NextResponse.redirect(`${requestUrl.origin}/dashboard`);
}

Database Schema and Row-Level Security

Create two tables in Supabase. The UI-driven approach works fine here, but you can also prompt the agent to generate SQL:

Generate the SQL to create:

1. users table:
   - id (UUID, primary key)
   - email (text)
   - created_at (timestamp)
   - subscription_status (text: 'free' | 'pro' | 'premium')
   - stripe_customer_id (text, unique)

2. articles table:
   - id (UUID, primary key)
   - user_id (UUID, foreign key to users.id)
   - title (text)
   - content (text)
   - is_premium (boolean)
   - created_at (timestamp)

Include Row-Level Security policies:
- Users can only view/edit their own articles
- Users can only view their own user record

The agent generates the migrations:

CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT auth.uid(),
  email TEXT UNIQUE NOT NULL,
  created_at TIMESTAMP DEFAULT NOW(),
  subscription_status TEXT DEFAULT 'free',
  stripe_customer_id TEXT UNIQUE
);
 
CREATE TABLE articles (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  content TEXT,
  is_premium BOOLEAN DEFAULT false,
  created_at TIMESTAMP DEFAULT NOW()
);
 
-- Row-Level Security
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE articles ENABLE ROW LEVEL SECURITY;
 
CREATE POLICY "Users can view their own profile"
  ON users FOR SELECT
  USING (auth.uid() = id);
 
CREATE POLICY "Users can update their own profile"
  ON users FOR UPDATE
  USING (auth.uid() = id);
 
CREATE POLICY "Users can view their own articles"
  ON articles FOR SELECT
  USING (auth.uid() = user_id);
 
CREATE POLICY "Users can insert their own articles"
  ON articles FOR INSERT
  WITH CHECK (auth.uid() = user_id);
 
CREATE POLICY "Users can update their own articles"
  ON articles FOR UPDATE
  USING (auth.uid() = user_id);
 
CREATE POLICY "Users can delete their own articles"
  ON articles FOR DELETE
  USING (auth.uid() = user_id);

Execute this in Supabase's SQL Editor, then in Antigravity create the helper functions:

Create lib/database.ts with these functions:
- fetchUserArticles(userId: string): Promise<Article[]>
- createArticle(userId: string, title: string, content: string): Promise<Article>
- updateArticle(articleId: string, title: string, content: string): Promise<Article>
- deleteArticle(articleId: string): Promise<void>
- getUserProfile(userId: string): Promise<User>
- updateUserProfile(userId: string, updates: Partial<User>): Promise<User>

Include proper TypeScript types and error handling.

Generated helper library:

import { supabase } from './supabase';
 
export interface User {
  id: string;
  email: string;
  subscription_status: 'free' | 'pro' | 'premium';
  stripe_customer_id: string | null;
  created_at: string;
}
 
export interface Article {
  id: string;
  user_id: string;
  title: string;
  content: string;
  is_premium: boolean;
  created_at: string;
}
 
export async function fetchUserArticles(userId: string): Promise<Article[]> {
  const { data, error } = await supabase
    .from('articles')
    .select('*')
    .eq('user_id', userId)
    .order('created_at', { ascending: false });
 
  if (error) throw new Error(error.message);
  return data || [];
}
 
export async function createArticle(
  userId: string,
  title: string,
  content: string
): Promise<Article> {
  const { data, error } = await supabase
    .from('articles')
    .insert([{
      user_id: userId,
      title,
      content,
      is_premium: false,
    }])
    .select()
    .single();
 
  if (error) throw new Error(error.message);
  return data;
}
 
export async function getUserProfile(userId: string): Promise<User> {
  const { data, error } = await supabase
    .from('users')
    .select('*')
    .eq('id', userId)
    .single();
 
  if (error) throw new Error(error.message);
  return data;
}
 
export async function updateUserProfile(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  const { data, error } = await supabase
    .from('users')
    .update(updates)
    .eq('id', userId)
    .select()
    .single();
 
  if (error) throw new Error(error.message);
  return data;
}

Stripe Integration: Checkout and Webhooks

Stripe payments involve three steps: creating a checkout session, handling the successful payment, and updating the user's subscription status via webhooks.

Create the checkout endpoint:

Create app/api/create-checkout-session/route.ts that:
- Receives POST with { userId, email }
- Creates a Stripe Checkout Session with mode: 'subscription'
- Uses line_items with price: NEXT_PUBLIC_STRIPE_PRICE_PRO
- Sets metadata.userId to track which user made the purchase
- Returns { sessionId }
- Handles errors with HTTP 500

The agent generates:

import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
export async function POST(req: NextRequest) {
  try {
    const { userId, email } = await req.json();
 
    const session = await stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      mode: 'subscription',
      line_items: [
        {
          price: process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO!,
          quantity: 1,
        },
      ],
      success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
      customer_email: email,
      metadata: {
        userId,
      },
    });
 
    return NextResponse.json({ sessionId: session.id });
  } catch (error: any) {
    console.error('Checkout session error:', error);
    return NextResponse.json(
      { error: error.message },
      { status: 500 }
    );
  }
}

Now the webhook handler that actually processes successful payments:

Create app/api/webhooks/stripe/route.ts that:
- Validates the request signature using Stripe's webhook secret
- Handles 'customer.subscription.updated' and 'customer.subscription.created' events
- Extracts metadata.userId from the subscription object
- Updates the user's subscription_status in Supabase to 'pro'
- Returns { received: true } on success
- Returns 400 on signature failure, 500 on other errors

Generated webhook handler:

import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
 
export async function POST(req: NextRequest) {
  const body = await req.text();
  const sig = req.headers.get('stripe-signature')!;
 
  let event: Stripe.Event;
 
  try {
    event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
  } catch (err: any) {
    console.error('Webhook signature verification failed:', err.message);
    return NextResponse.json(
      { error: `Webhook error: ${err.message}` },
      { status: 400 }
    );
  }
 
  if (
    event.type === 'customer.subscription.created' ||
    event.type === 'customer.subscription.updated'
  ) {
    const subscription = event.data.object as Stripe.Subscription;
    const userId = subscription.metadata?.userId;
 
    if (!userId) {
      console.warn('Subscription event missing userId metadata');
      return NextResponse.json({ received: true });
    }
 
    const { error } = await supabase
      .from('users')
      .update({
        subscription_status: 'pro',
        stripe_customer_id: subscription.customer as string,
      })
      .eq('id', userId);
 
    if (error) {
      console.error('Failed to update user subscription:', error.message);
      return NextResponse.json(
        { error: error.message },
        { status: 500 }
      );
    }
  }
 
  if (event.type === 'customer.subscription.deleted') {
    const subscription = event.data.object as Stripe.Subscription;
    const userId = subscription.metadata?.userId;
 
    if (userId) {
      await supabase
        .from('users')
        .update({ subscription_status: 'free' })
        .eq('id', userId);
    }
  }
 
  return NextResponse.json({ received: true });
}

To test the webhook locally, Antigravity's terminal gives you access to the Stripe CLI:

stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger customer.subscription.created

Watch the logs in Antigravity as the webhook fires and the database updates. No context switching needed.

Building the Dashboard

Once a user is authenticated, they need somewhere to manage their subscription and articles. Create the dashboard:

Create app/(authenticated)/dashboard/page.tsx with:
- Fetch current user from Supabase Auth
- Display user's subscription_status
- Fetch and display user's articles
- "Write Article" button linking to /articles/new
- "Upgrade to Pro" button if subscription_status is 'free'
- "Manage Subscription" button linking to Stripe customer portal
- Logout button

Handle loading and error states. Use TypeScript throughout.

The agent generates:

'use client';
 
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase';
import { useRouter } from 'next/navigation';
import type { Article, User } from '@/lib/database';
 
export default function DashboardPage() {
  const router = useRouter();
  const [user, setUser] = useState<User | null>(null);
  const [articles, setArticles] = useState<Article[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    async function loadData() {
      try {
        const { data: { user: authUser } } = await supabase.auth.getUser();
 
        if (!authUser) {
          router.push('/login');
          return;
        }
 
        const { data: userData, error: userError } = await supabase
          .from('users')
          .select('*')
          .eq('id', authUser.id)
          .single();
 
        if (userError) throw userError;
 
        const { data: articlesData, error: articlesError } = await supabase
          .from('articles')
          .select('*')
          .eq('user_id', authUser.id)
          .order('created_at', { ascending: false });
 
        if (articlesError) throw articlesError;
 
        setUser(userData);
        setArticles(articlesData || []);
      } catch (err: any) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }
 
    loadData();
  }, [router]);
 
  async function handleUpgrade() {
    try {
      const response = await fetch('/api/create-checkout-session', {
        method: 'POST',
        body: JSON.stringify({
          userId: user!.id,
          email: user!.email,
        }),
      });
 
      const { sessionId } = await response.json();
      
      // Redirect to Stripe Checkout
      window.location.href = `https://checkout.stripe.com/pay/${sessionId}`;
    } catch (err: any) {
      setError(err.message);
    }
  }
 
  if (loading) {
    return <div className="flex items-center justify-center min-h-screen">Loading...</div>;
  }
 
  if (error) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="text-red-600">{error}</div>
      </div>
    );
  }
 
  return (
    <div className="max-w-4xl mx-auto p-6">
      <div className="flex justify-between items-center mb-8">
        <h1 className="text-3xl font-bold">Dashboard</h1>
        <button
          onClick={() => supabase.auth.signOut().then(() => router.push('/'))}
          className="text-sm text-gray-600"
        >
          Sign out
        </button>
      </div>
 
      <div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
        <h2 className="text-lg font-semibold mb-2">Your Plan</h2>
        <p className="text-3xl font-bold capitalize mb-4">
          {user?.subscription_status}
        </p>
        {user?.subscription_status === 'free' && (
          <button
            onClick={handleUpgrade}
            className="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700"
          >
            Upgrade to Pro
          </button>
        )}
      </div>
 
      <div>
        <div className="flex justify-between items-center mb-6">
          <h2 className="text-2xl font-bold">Your Articles</h2>
          <a
            href="/articles/new"
            className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700"
          >
            Write Article
          </a>
        </div>
 
        {articles.length === 0 ? (
          <p className="text-gray-600">No articles yet. Start writing!</p>
        ) : (
          <div className="space-y-4">
            {articles.map((article) => (
              <a
                key={article.id}
                href={`/articles/${article.id}`}
                className="block border rounded-lg p-4 hover:bg-gray-50 transition"
              >
                <h3 className="font-semibold text-lg mb-1">{article.title}</h3>
                <p className="text-sm text-gray-500">
                  {new Date(article.created_at).toLocaleDateString()}
                </p>
              </a>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

Deploying to Cloudflare Workers

Once everything works locally, deploy to Cloudflare Workers. In Antigravity's terminal, run:

npm install -g wrangler
wrangler login

Create the deployment config:

Create wrangler.toml with:
- name: my-saas-app
- type: javascript
- compatibility_date: 2024-01-01
- Build command: npm install && npm run build
- Production route: (your actual domain)

Generated config:

name = "my-saas-app"
type = "javascript"
compatibility_date = "2024-01-01"
 
[build]
command = "npm install && npm run build"
cwd = "./"
 
[env.production]
routes = [
  { pattern = "myapp.com/*", zone_name = "myapp.com" }
]

Deploy directly from the terminal:

wrangler deploy --env production

That's it. Your SaaS is live on Cloudflare's global network. No context switching, no leaving the IDE.

For custom domains, still from the terminal:

wrangler domains create myapp.com --environment production

Testing the Full Flow

Before calling it done, test end-to-end:

  1. Visit the live site
  2. Sign up with a new email
  3. Log in
  4. View the dashboard
  5. Upgrade to Pro using Stripe test card 4242 4242 4242 4242
  6. Confirm subscription_status changed to 'pro' in the database

You can monitor everything from Antigravity's logs. Write an article, create a test checkout session, trigger a test webhook — all without leaving the IDE.

Why This Approach Matters

Most developers spend their day context-switching between tools. IDE, browser, terminal, dashboard, deploy tool. Each switch costs focus. By hour's end, you've lost 10+ minutes to navigation alone.

Antigravity collapses this. One IDE. One terminal. One AI agent that understands your entire stack and can extend it on command.

The SaaS you just built is production-grade: Supabase handles auth securely, Stripe webhooks ensure reliable billing, RLS guarantees data privacy, and Cloudflare Workers delivers globally with minimal latency.

This is the future of full-stack development. Not multiple specialized tools, but a unified environment where code, infrastructure, and deployment are one seamless workflow.

Your next SaaS starts in Antigravity. Keep the browser closed.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

App Dev2026-04-06
Antigravity × Multi-Tenant SaaS Complete Implementation Guide: Supabase RLS × Stripe Metered Billing × RBAC
A production-grade guide to building multi-tenant SaaS with Antigravity IDE. Learn tenant isolation with Supabase Row Level Security, usage-based billing with Stripe Metered Billing, and fine-grained access control with RBAC — with full implementation code.
Tips2026-04-06
Building a SaaS MVP with Antigravity: A 90-Day Roadmap from Idea to First Revenue
A complete 90-day roadmap for indie developers to build and monetize a SaaS MVP using Antigravity. Covers idea validation, tech stack selection, core feature implementation, Stripe billing, and acquiring your first paying customers.
App Dev2026-04-27
Building Webhook Endpoints in Antigravity That Never Drop Events
A practical guide to designing, implementing, and testing webhook endpoints for Stripe and GitHub using Antigravity, focused on the three properties that prevent dropped events: signature verification, idempotency, and fast response.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →