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.createdWatch 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 loginCreate 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 productionThat'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 productionTesting the Full Flow
Before calling it done, test end-to-end:
- Visit the live site
- Sign up with a new email
- Log in
- View the dashboard
- Upgrade to Pro using Stripe test card
4242 4242 4242 4242 - 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.