Accelerating SaaS Development with AI Agents
Building a SaaS application requires three foundational pillars: authentication, billing, and a database. Traditionally, integrating these systems could take weeks. With Antigravity's AI agent, you can dramatically reduce the time from architecture to working code.
This is a sample of the kind of in-depth, practical content available to Antigravity Lab members. If you find this valuable, consider joining our membership for more advanced implementation guides.
Project Setup
Start by having Antigravity's agent scaffold the project. When you specify the exact tech stack, the agent can suggest an optimal initial configuration.
# In Antigravity's terminal, instruct the agent:
# "Create a Next.js 15 App Router + TypeScript + Tailwind CSS SaaS scaffold.
# Use Auth.js v5 for auth, Cloudflare D1 for database, Stripe for billing."
npx create-next-app@latest my-saas --typescript --tailwind --app --src-dir
cd my-saas
# Install required packages
npm install next-auth@beta @auth/drizzle-adapter drizzle-orm stripe
npm install -D drizzle-kit wranglerUse Antigravity's inline chat (Cmd+I) to iteratively generate each file through natural conversation.
Implementing Authentication with Auth.js v5
Auth.js v5 (NextAuth.js v5) natively supports the Next.js App Router. Let the agent know your design requirements, and it will generate the authentication configuration.
// src/lib/auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "./db";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db),
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
callbacks: {
async session({ session, user }) {
// Add user ID and subscription info to session
session.user.id = user.id;
return session;
},
},
pages: {
signIn: "/login",
error: "/auth/error",
},
});// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;The key here is using DrizzleAdapter to connect directly with Cloudflare D1. This stores user data in a D1 database close to the edge, making authentication requests faster.
Cloudflare D1 Database Design
D1 is a SQLite-based database that runs on Cloudflare's edge network. We'll define the schema using Drizzle ORM.
// src/db/schema.ts
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
// Auth.js tables
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
name: text("name"),
email: text("email").notNull().unique(),
emailVerified: integer("emailVerified", { mode: "timestamp" }),
image: text("image"),
stripeCustomerId: text("stripe_customer_id"),
plan: text("plan").default("free"), // free, pro, premium
planExpiresAt: integer("plan_expires_at", { mode: "timestamp" }),
});
export const accounts = sqliteTable("accounts", {
id: text("id").primaryKey(),
userId: text("userId").notNull().references(() => users.id),
type: text("type").notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
});
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
sessionToken: text("sessionToken").notNull().unique(),
userId: text("userId").notNull().references(() => users.id),
expires: integer("expires", { mode: "timestamp" }).notNull(),
});
// SaaS-specific tables
export const subscriptions = sqliteTable("subscriptions", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id),
stripeSubscriptionId: text("stripe_subscription_id").unique(),
plan: text("plan").notNull(), // pro, premium
status: text("status").notNull(), // active, canceled, past_due
currentPeriodEnd: integer("current_period_end", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }),
});# Generate and apply migrations
npx drizzle-kit generate
npx wrangler d1 migrations apply my-saas-db --localImplementing the Stripe Billing Flow
Create an API route that handles Stripe Checkout for authenticated users.
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const PLANS = {
pro: {
priceId: process.env.STRIPE_PRO_PRICE_ID!,
name: "Pro",
},
premium: {
priceId: process.env.STRIPE_PREMIUM_PRICE_ID!,
name: "Premium",
},
} as const;
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { plan } = await req.json();
if (!PLANS[plan as keyof typeof PLANS]) {
return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
}
const selectedPlan = PLANS[plan as keyof typeof PLANS];
// Get or create Stripe Customer
const user = await db.select().from(users)
.where(eq(users.id, session.user.id))
.get();
let customerId = user?.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: session.user.email!,
metadata: { userId: session.user.id },
});
customerId = customer.id;
await db.update(users)
.set({ stripeCustomerId: customerId })
.where(eq(users.id, session.user.id));
}
// Create Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: plan === "premium" ? "payment" : "subscription",
line_items: [{ price: selectedPlan.priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard?upgraded=true`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing`,
metadata: {
userId: session.user.id,
plan,
},
});
return NextResponse.json({ url: checkoutSession.url });
}Protecting Routes with Auth Middleware
Implement middleware to protect pages that require authentication.
// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isProtectedRoute = req.nextUrl.pathname.startsWith("/dashboard");
const isApiRoute = req.nextUrl.pathname.startsWith("/api/checkout");
if (isProtectedRoute && !isLoggedIn) {
return NextResponse.redirect(new URL("/login", req.url));
}
if (isApiRoute && !isLoggedIn) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.next();
});
export const config = {
matcher: ["/dashboard/:path*", "/api/checkout"],
};Syncing Payment State with Webhooks
Implement a Webhook handler to receive Stripe events and update subscription status in your database.
// src/app/api/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { db } from "@/lib/db";
import { users, subscriptions } from "@/db/schema";
import { eq } from "drizzle-orm";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 }
);
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
const plan = session.metadata?.plan;
if (userId && plan) {
await db.update(users)
.set({ plan })
.where(eq(users.id, userId));
}
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
const userId = sub.metadata?.userId;
if (userId) {
await db.update(users)
.set({ plan: "free" })
.where(eq(users.id, userId));
}
break;
}
}
return NextResponse.json({ received: true });
}Looking back
By leveraging Antigravity's AI agent, you can efficiently build a fully-featured SaaS application with Auth.js v5 + Stripe + Cloudflare D1. While integrating authentication, billing, and database layers is inherently complex, communicating your architectural vision to the agent helps maintain a consistent codebase throughout development.
To get started with Antigravity basics, check out "What is Google Antigravity?". For more on backend development, see "Supabase × Antigravity Backend Guide". To improve how you communicate with AI, "Prompt Engineering Fundamentals" is also worth reading.