Antigravity × Cloudflare Edge SaaS Architecture Guide: Production Patterns with D1, Workers, R2, and Workers AI
A complete production guide for building edge SaaS with Antigravity + Cloudflare D1, Workers, R2, and Workers AI. Covers real pitfalls including N+1 queries, CPU limits, and cost management.
When I first tried to build a SaaS using Cloudflare's service lineup, the immediate problem wasn't technical — it was conceptual. D1, Workers, Pages, R2, KV, Workers AI… reading the documentation for each one individually gave me no clear picture of how they fit together. I went ahead with "let's just use everything" and ended up with unexpected costs and a Workers CPU limit error in production that took an afternoon to diagnose.
Since starting to use Antigravity for Cloudflare development, that experience has changed significantly. Delegating configuration files, Drizzle schema generation, and Hono route scaffolding to Antigravity means I spend my time on architectural decisions rather than boilerplate. The codebase stays consistent because Antigravity applies the constraints I've defined in agents.md across every generated file.
This guide documents the production architecture patterns I've settled on after building and iterating on Cloudflare edge SaaS applications. The focus is on patterns that work in production — not toy examples.
Why Cloudflare, and the Unexpected Challenges
For solo developers building SaaS, infrastructure costs matter. After cycling through Vercel + Supabase, AWS Lambda + RDS, and Firebase Hosting + Firestore, I landed on Cloudflare for three reasons.
Edge execution: Workers runs in 300+ edge locations globally. For applications where API latency directly affects UX — especially mobile apps — the difference is noticeable. Users in Tokyo, Berlin, and São Paulo all get responses from a nearby location rather than a single origin.
Cost model transparency: Workers billing is request-based (100K requests/day free). In the early stages when traffic is low, costs are near zero. D1 offers 5GB free storage, making MVP-phase operation essentially free.
Local development parity: wrangler dev emulates the production environment closely enough that "works locally, breaks in production" incidents are rare compared to other platforms.
That said, the challenges I didn't anticipate were real: D1's eventual consistency behavior, Workers' CPU time limits, Workers AI's model loading overhead, and R2's presigned URL expiration edge cases. These are the things you discover in production, not from documentation.
The Architecture Blueprint: What Goes Where
The most important design decision in an edge SaaS is determining what gets processed at the edge. Here's the role allocation I've settled on:
User
↓
Cloudflare Pages (Next.js frontend)
↓ API requests
Cloudflare Workers (API server / auth / business logic)
├─ D1 (SQLite database / user data / content)
├─ KV (sessions / short-lived cache / feature flags)
├─ R2 (file storage / images / documents)
└─ Workers AI (lightweight inference / text classification)
↑ Heavy inference → external APIs (Gemini / OpenAI)
Use D1 for: User data, content, anything requiring transactions or relational queries. SQL means existing knowledge transfers directly.
Use KV for: Session storage, short-lived caches with TTL, feature flags. Faster than D1 for key-value lookups but without strong consistency guarantees.
Use R2 for: User-uploaded files, generated images, PDFs. S3-compatible API means most existing S3 code works without modification.
Use Workers AI for: Lightweight inference that can't leave Cloudflare's infrastructure for privacy reasons. For quality-sensitive or complex generation tasks, external APIs are consistently better.
When I prompt Antigravity with "implement the routing layer based on this architecture diagram," it generates Hono-based router code immediately. Workers-specific constraints (CPU time, memory limits) still need manual review, which is why documenting them in agents.md upfront matters.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Why the Worker bundle hits the 62 MiB limit as articles grow, and the content split that moves bodies to ASSETS
✦Reconciling edge caching with members-only full text via a premium_token bypass and DEPLOY_VERSION invalidation
✦A production guard that stops 200-status SSR error pages from being burned into the edge cache
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
D1 + Drizzle ORM: Schema Design and the N+1 Reality
D1 is SQLite running at Cloudflare's edge. The common question — "is D1 actually production-ready?" — has a nuanced answer: for read-heavy workloads (content delivery, SaaS dashboards, API serving), it works well. For write-heavy workloads with complex transactions, the tradeoffs become more significant.
Drizzle ORM is the de facto standard for D1 in 2026. Its TypeScript inference is excellent and it handles D1's SQLite dialect reliably.
Because D1 queries involve network round-trips to Cloudflare's edge database layer, the N+1 problem — where one query per item is issued in a loop — is more painful than with a co-located database.
// ❌ N+1 problem: one query per userasync function getUsersWithSubscriptions(db: DrizzleD1Database) { const users = await db.select().from(usersTable); const result = await Promise.all( users.map(async (user) => ({ ...user, subscription: await db.select() .from(subscriptionsTable) .where(eq(subscriptionsTable.userId, user.id)) .get(), })) ); return result;}// ✅ Single query with JOINasync function getUsersWithSubscriptions(db: DrizzleD1Database) { return db .select({ user: usersTable, subscription: subscriptionsTable }) .from(usersTable) .leftJoin(subscriptionsTable, eq(usersTable.id, subscriptionsTable.userId));}
When JOINs aren't possible, D1's batch API handles multiple statements in a single round-trip:
// ✅ D1 Batch API: multiple queries in one round-tripasync function batchFetchUserData(env: Env, userId: string) { const batchResult = await env.DB.batch([ env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(userId), env.DB.prepare('SELECT * FROM subscriptions WHERE user_id = ?').bind(userId), env.DB.prepare('SELECT COUNT(*) as count FROM articles WHERE author_id = ?').bind(userId), ]); // Expected output: // batchResult[0].results → [{ id: '...', email: '...', plan_type: 'pro' }] // batchResult[1].results → [{ id: '...', status: 'active' }] // batchResult[2].results → [{ count: 42 }] return { user: batchResult[0].results[0], subscription: batchResult[1].results[0], articleCount: (batchResult[2].results[0] as { count: number }).count, };}
Adding agents.md context like "always use JOIN or Batch API to avoid N+1 queries in D1" means Antigravity applies this pattern automatically in generated code.
Workers API: Authentication, Routing, and Rate Limiting
Hono is the standard framework for Workers-based APIs — lightweight, Workers-optimized, and well-maintained.
The default CPU time limit is 10ms (50ms on Paid plans). This is CPU time — I/O wait time (D1 queries, R2 operations) doesn't count. The limit catches heavy computation: large array sorts, complex string processing, or parsing large JSON bodies.
// ❌ Heavy in-memory processing hits CPU limitsfunction processLargeDataset(data: unknown[]) { return data .filter(/* complex filtering */) .sort(/* complex sort */) .map(/* heavy transformation */);}// ✅ Push computation to D1 (SQL handles sorting/filtering)async function processLargeDataset(db: DrizzleD1Database, filters: FilterOptions) { return db .select() .from(articlesTable) .where(and( eq(articlesTable.status, 'published'), gte(articlesTable.createdAt, filters.fromDate), )) .orderBy(desc(articlesTable.createdAt)) .limit(50); // Always LIMIT — returning thousands of rows is never the right move}
R2 File Management: Presigned URLs and Cost Modeling
R2 is Cloudflare's object storage. The key advantage over S3: zero egress fees when serving through Cloudflare's CDN.
// src/handlers/upload.tsconst uploadRouter = new Hono<{ Bindings: { R2: R2Bucket; DB: D1Database } }>();// Generate presigned URL for direct client-to-R2 upload// Never route file uploads through Workers — memory limits (128MB) will bite youuploadRouter.post('/api/upload/presign', async (c) => { const { filename, contentType } = await c.req.json<{ filename: string; contentType: string; }>(); // Sanitize filename before using as R2 key const sanitizedFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '_'); const key = `uploads/${Date.now()}-${sanitizedFilename}`; // Whitelist content types const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']; if (!allowedTypes.includes(contentType)) { return c.json({ error: 'Unsupported file type' }, 400); } // Presigned URL valid for 5 minutes const url = await c.env.R2.createPresignedUrl(key, { method: 'PUT', expiresIn: 300, httpMetadata: { contentType }, }); // Expected output: { uploadUrl: "https://...", key: "uploads/1234567890-file.jpg" } return c.json({ uploadUrl: url, key });});// Record completed upload in D1uploadRouter.post('/api/upload/complete', async (c) => { const user = c.get('user'); const { key } = await c.req.json<{ key: string }>(); // Verify the file actually exists in R2 before recording const object = await c.env.R2.head(key); if (!object) { return c.json({ error: 'File not found in R2 storage' }, 404); } const db = drizzle(c.env.DB); await db.insert(filesTable).values({ id: ulid(), userId: user.id, key, size: object.size, contentType: object.httpMetadata?.contentType ?? 'application/octet-stream', createdAt: new Date(), }); return c.json({ success: true, key });});
Real Cost Numbers
For an app with 1,000 uploads/month averaging 2MB each:
Storage: 2GB × $0.015/GB = $0.03
Class A operations (writes): 1,000 × $4.50/million = $0.0045
Egress through Cloudflare CDN: $0
For high-frequency read scenarios, serving objects through Cloudflare's CDN cache is far cheaper than hitting R2 directly on every request. Configure cache headers appropriately.
Workers AI: Where It Fits and Where It Doesn't
Workers AI runs AI model inference at Cloudflare's edge. The use case is specific: lightweight inference where data can't leave Cloudflare's infrastructure.
// src/handlers/ai-classify.ts// Text sentiment classification — fast and privateasync function classifyUserInput( env: Env, text: string): Promise<{ category: string; confidence: number }> { const result = await env.AI.run('@cf/huggingface/distilbert-sst-2-int8', { text, }) as { label: string; score: number }[]; // Expected output: // [{ label: "POSITIVE", score: 0.9987 }, { label: "NEGATIVE", score: 0.0013 }] if (!result || result.length === 0) { throw new Error('Workers AI returned empty result'); } const topResult = result.sort((a, b) => b.score - a.score)[0]; return { category: topResult.label, confidence: topResult.score };}// Short text summarizationasync function summarizeText(env: Env, text: string): Promise<string> { // Workers AI models have input token limits — truncate proactively const truncatedText = text.slice(0, 1000); const result = await env.AI.run('@cf/facebook/bart-large-cnn', { input_text: truncatedText, max_length: 150, }) as { summary: string }; return result.summary;}
When NOT to use Workers AI
The honest answer is that Workers AI is appropriate for a narrow set of use cases. For anything requiring high quality output — complex reasoning, long-form generation, high-resolution image generation, real-time speech recognition at quality — external APIs (Gemini, Claude, OpenAI) are simply better on both quality and cost per token.
Workers AI's real value proposition: you own the compute, data stays in Cloudflare's infrastructure. For enterprise features where user data privacy is contractually required, this matters significantly.
Stripe Integration: Webhooks with D1 State Management
// src/handlers/stripe-webhook.tsimport Stripe from 'stripe';export async function handleStripeWebhook( request: Request, env: Env,): Promise<Response> { const body = await request.text(); const signature = request.headers.get('stripe-signature'); if (!signature) { return new Response('Missing Stripe signature', { status: 400 }); } let event: Stripe.Event; try { // Workers requires async webhook verification with SubtleCrypto // constructEvent (sync) does NOT work in Workers — use constructEventAsync const stripe = new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: '2025-01-27.acacia' }); event = await stripe.webhooks.constructEventAsync( body, signature, env.STRIPE_WEBHOOK_SECRET, undefined, Stripe.createSubtleCryptoProvider(), ); } catch (err) { console.error('Stripe webhook verification failed:', err); return new Response('Invalid webhook signature', { status: 400 }); } const db = drizzle(env.DB); switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as Stripe.CheckoutSession; const userId = session.metadata?.userId; if (!userId) { console.error('checkout.session.completed missing userId in metadata'); break; } await db.insert(subscriptionsTable) .values({ id: ulid(), userId, stripeSubscriptionId: session.subscription as string, status: 'active', currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), updatedAt: new Date(), }) .onConflictDoUpdate({ target: subscriptionsTable.userId, set: { status: 'active', stripeSubscriptionId: session.subscription as string, updatedAt: new Date(), }, }); // Cache in KV for fast dashboard loads await env.KV.put( `subscription:${userId}`, JSON.stringify({ status: 'active' }), { expirationTtl: 3600 }, ); break; } case 'customer.subscription.deleted': { const subscription = event.data.object as Stripe.Subscription; const userId = subscription.metadata?.userId; if (!userId) break; await db.update(subscriptionsTable) .set({ status: 'canceled', updatedAt: new Date() }) .where(eq(subscriptionsTable.userId, userId)); await env.KV.delete(`subscription:${userId}`); break; } } return new Response(JSON.stringify({ received: true }), { headers: { 'Content-Type': 'application/json' }, });}
Five Production Pitfalls and How to Solve Them
These are the specific issues I hit after deploying. Knowing them upfront saves several hours of debugging.
Pitfall 1: D1 eventual consistency after writes
D1 replicates data across read replicas, and there's a brief window after a write where reads may return stale data. The symptom: user registers, gets redirected to their dashboard, and sees "user not found." The write happened on a primary — the read hit a replica that hadn't caught up yet.
Solution: After writes that the user immediately reads back, either write the fresh data to KV as a short-lived cache, add a brief wait (100-500ms), or redesign the flow to avoid reading immediately after writing.
Pitfall 2: 128MB memory limit with large payloads
Workers has a 128MB memory limit. Fetching thousands of rows from D1 and serializing to JSON can approach this limit.
Solution: Always add LIMIT to queries (100 rows is a reasonable maximum for most API endpoints). For large data exports, use TransformStream to stream the response rather than building the full payload in memory.
Pitfall 3: Presigned URL expiration during slow uploads
If a user takes more than 5 minutes to select and start uploading a file, the presigned URL expires. The upload fails with a confusing 403 error.
Solution: Fetch the presigned URL just before the upload starts (not when the upload form loads). Alternatively, extend the expiration to 15 minutes for large file uploads.
Pitfall 4: wrangler.toml section ordering
If [build] appears before top-level fields (name, main, account_id, compatibility_date) in wrangler.toml, deployment fails with a cryptic error.
Solution: Always place top-level fields at the top of wrangler.toml. [build] and other section headers come after. Add this to agents.md so Antigravity generates correctly-ordered config files.
Pitfall 5: CORS misconfiguration between Pages and Workers
When a Pages frontend calls a Workers API, the origins differ between development (localhost) and production (pages.dev, custom domain). Missing CORS headers on Workers causes silent failures in the browser.
Solution: Manage the CORS origin list via environment variable in Workers. In development: ["http://localhost:3000"]. In production: ["https://yourapp.pages.dev", "https://yourapp.com"].
Optimizing Antigravity for Cloudflare Development
The agents.md file in your project repository is read by Antigravity at the start of each session. Investing time in documenting Cloudflare-specific constraints here pays dividends across every generated file.
# Project Context: Cloudflare Edge SaaS## Critical Constraints- Workers CPU: 10ms default (50ms on Paid plan). Heavy computation → push to D1/KV- D1 consistency: eventual after writes. Don't read immediately after write without cache- Workers memory: 128MB. All queries must have LIMIT (max 100 rows)- R2 uploads: NEVER route through Workers. Always use presigned URLs- Stripe webhooks: constructEventAsync + SubtleCryptoProvider (not the sync version)- wrangler.toml: top-level fields before any [section] headers## Stack- Runtime: Cloudflare Workers + Hono- ORM: Drizzle ORM (D1 binding)- Frontend: Next.js on Cloudflare Pages- Billing: Stripe (webhooks → D1 state)- File storage: R2 with presigned URLs
With this context, Antigravity avoids generating code that exceeds CPU limits, routes files through Workers, or uses the synchronous Stripe webhook verification method.
Local Development: Making wrangler dev Work for You
# Initialize local D1 database from migration fileswrangler d1 execute my-db --local --file=./migrations/0001_initial.sql# Run all bindings locally (D1, KV, R2, Workers AI all emulated)wrangler dev --local# Inspect local D1 datawrangler d1 execute my-db --local --command="SELECT * FROM users LIMIT 10"
Local emulation covers D1, KV, R2, and Workers AI. Production surprises are mostly limited to the eventual consistency behavior and real network latency — which local emulation can't replicate.
Deployment Pipeline with Health Checks
#!/bin/bashset -eecho "🚀 Deploying to staging..."wrangler deploy --env staging# Verify staging is healthy before touching productionHEALTH=$(curl -sf https://staging.yourapp.workers.dev/api/health | jq -r '.status' 2>/dev/null)if [ "$HEALTH" != "ok" ]; then echo "❌ Staging health check failed — aborting production deploy" exit 1fiecho "✅ Staging healthy. Deploying to production..."wrangler deploy --env productionecho "🎉 Production deployment complete"
Testing Workers Before Deployment
Integration testing for Workers is handled by Vitest with @cloudflare/vitest-pool-workers. This runs your tests inside an actual Workers runtime — not Node.js — which catches a category of issues that mocked tests miss entirely.
Running tests inside the Workers runtime means D1 queries, KV reads, and R2 operations execute with the same constraints as production. A test that passes in Node.js doesn't guarantee it passes in Workers — the CPU time measurement and available APIs differ. Using @cloudflare/vitest-pool-workers closes that gap.
Monitoring and Observability on Cloudflare
One challenge with Cloudflare Workers is that traditional observability tools assume a long-running server process. Workers are stateless and ephemeral — each request gets a fresh execution context. This changes how you approach logging and error tracking.
Cloudflare Analytics Engine is the right tool for custom metrics. It emits structured data points from Workers and lets you query them via GraphQL.
// src/middleware/observability.tstype AnalyticsEvent = { endpoint: string; method: string; statusCode: number; durationMs: number; userId?: string; planType?: string;};function recordRequestEvent(env: Env, event: AnalyticsEvent): void { // Analytics Engine writeDataPoint is non-blocking — fire and forget // Do NOT await this — it adds measurable latency env.ANALYTICS.writeDataPoint({ blobs: [event.endpoint, event.method, event.userId ?? 'anonymous'], doubles: [event.statusCode, event.durationMs], indexes: [event.planType ?? 'free'], });}// Hono middleware that records every requestexport const observabilityMiddleware = async (c: Context, next: Next) => { const startMs = Date.now(); await next(); recordRequestEvent(c.env, { endpoint: new URL(c.req.url).pathname, method: c.req.method, statusCode: c.res.status, durationMs: Date.now() - startMs, userId: c.get('user')?.id, planType: c.get('user')?.planType, });};
The metrics worth tracking for edge SaaS health: P99 response time per endpoint, D1 query count per request (high counts surface N+1 problems), Workers CPU time approaching the 10ms limit, KV cache hit rate (unexpectedly low hit rate signals a cache invalidation bug), and R2 Class A operation count per day for cost control.
Managing Secrets and Environment Variables
Cloudflare Workers distinguishes between three types of configuration values. Conflating them creates security issues or operational headaches.
Secrets (API keys, webhook signing secrets) never go in wrangler.toml. Store them encrypted via the Wrangler CLI:
# Store secrets — values are encrypted and unreadable after storagewrangler secret put STRIPE_SECRET_KEYwrangler secret put STRIPE_WEBHOOK_SECRET# Use environment-specific configuration in a single file# [env.production] and [env.staging] avoid duplicated wrangler.toml files
A common operational mistake: copying wrangler.toml between environments with [vars] values that differ between staging and production. Use [env.production] and [env.staging] sections within the same file to manage this cleanly.
Database Migrations: Keeping D1 Schemas in Sync Across Environments
Schema drift between local, staging, and production D1 databases is a category of problem that becomes painful once you have real user data.
# Generate a new migration filewrangler d1 migrations create my-db add_user_preferences# Apply pending migrations locallywrangler d1 migrations apply my-db --local# Apply to production D1 (run this explicitly — it's not automatic)wrangler d1 migrations apply my-db# Check migration statuswrangler d1 migrations list my-db
The migration file itself is a plain SQL file that Wrangler tracks:
-- migrations/0002_add_user_preferences.sqlALTER TABLE users ADD COLUMN preferences TEXT DEFAULT '{}';CREATE INDEX pref_idx ON users(id) WHERE preferences != '{}';
One thing that surprises developers coming from Prisma or Rails: D1 migrations are not automatically reversible. There's no migrate:down. Design migrations to be additive (new columns, new tables) wherever possible. For destructive changes, use a blue-green approach: add the new column, backfill data, then drop the old column in a separate migration after verifying data integrity.
Antigravity handles migration file generation well when given the schema diff. "Generate a D1 migration that adds a preferences column to users and backfills it with the default value" produces the correct SQL immediately, including the index if the pattern suggests it will be queried.
Beyond the sample app: boundaries I only saw running several sites on Workers
Everything so far has been framed around building one SaaS. What I actually live with day to day is different: keeping several production sites running at the same time on the same Cloudflare stack. I run four content sites, each a Next.js app on Workers. Walls that stayed invisible with a single app showed their faces clearly once the count went up.
In this section I want to share three constraints you never hit in a sample app — the kind you only learn by keeping a site running in production.
The 62 MiB Worker bundle limit, and moving content out
The first painful lesson was a "bundle too large" error at deploy time. I had been shipping article metadata and body HTML together in one JSON, bundled into the Worker. Once the article count passed a few hundred, the bundle crept toward the 62 MiB ceiling.
Workers replicate the executable itself to every edge location. That is exactly why baking heavy, read-time-only data into the runtime bundle works against you as you scale.
The fix was to separate the data into layers.
Keep only metadata — title, slug, category — in articles.json
Emit each body as an individual file at public/content/articles/{locale}/{category}/{slug}.html
At request time, fetch just the one file you need through the ASSETS binding
// content.ts — fetch the body from ASSETS, never from the bundleexport async function getArticleContent( locale: string, category: string, slug: string): Promise<string | null> { const { env } = getCloudflareContext(); const url = `https://assets.local/content/articles/${locale}/${category}/${slug}.html`; const res = await env.ASSETS.fetch(url); if (!res.ok) return null; return res.text();}
One trap here: it is tempting to think "I'll just fetch() my own hostname." Inside a Worker, a fetch to your own host does not resolve the way you expect. Always go through the ASSETS binding's fetch — that is the one point I won't compromise on.
Once metadata and body were split, the bundle stayed a constant weight no matter how many articles I added. The line between "something that runs" and "something you can keep operating" sits right here.
Edge cache colliding with "show the full text to members only"
Edge caching is the heart of speed, but in a product that delivers full articles only to paying readers, using it as-is becomes an incident. A full-text page cached by one user gets served verbatim to a non-member.
I put a thin cache worker in front of Workers and inserted exactly one decision: any request carrying a premium_token, or a cookie indicating an article purchase, skips the cache and always goes to the origin.
// cache-worker.js — members and buyers bypass the cacheconst cookie = request.headers.get("Cookie") || "";const isMember = cookie.includes("premium_token") || cookie.includes("article_purchases");if (isMember) { return fetch(request); // never touch the cache; always return fresh}
I also keep a single DEPLOY_VERSION constant so I can expire stale cache wholesale on each deploy. Changing that value invalidates every edge cache at once, which prevents an old body from lingering after I replace an article.
"Fast" and "personalized" will collide if you leave them alone. Deciding where to draw that boundary up front removed almost all of the rework later.
A momentary error page burned into the cache
The hardest one to spot was this. Next.js can throw during SSR streaming yet still emit the error UI with an HTTP status of 200. In the instant a deploy switches over, a page with an empty body, or an error screen, can be generated as a perfectly "valid 200."
A naive cache worker treats "a 200 HTML" as good and pins that broken page to the edge for hours. The intermittent "failed to load" that a reload fixes — that was its true cause.
The countermeasure is to distrust the body before caching it.
// don't cache broken HTMLconst body = await response.clone().text();const looksBroken = body.includes("data-error-boundary") || // an error boundary rendered !body.includes("</html>") || // HTML was cut off mid-stream body.includes('class="article-content"></'); // empty bodyif (looksBroken) { return response; // return it, but never store it in the cache}
I tag error boundaries with a marker attribute, and also detect an empty body or a missing </html>. As insurance against a momentary asset-read failure at the edge, I retry the read exactly once.
After these three guards went in, the intermittent load errors quietly stopped. The edge is fast — which means it serves the wrong thing just as fast and just as widely. Operating it is where I finally understood that in my bones.
Next Steps
Cloudflare's edge stack is a genuinely compelling choice for solo developers building SaaS. Near-zero infrastructure costs in early stages, global edge execution, and a local development experience that closely mirrors production — these make it worth the learning curve.
The key is understanding the constraints before writing code: D1's eventual consistency, Workers' CPU limits, R2's presigned URL requirement, and the Stripe async webhook verification. These aren't dealbreakers — they're just parameters to design around.
The architecture described here — Workers as API layer, D1 for persistent state, KV for sessions and caching, R2 for files, Workers AI for lightweight on-device inference — covers the vast majority of SaaS requirements without leaving Cloudflare's ecosystem. That's a genuine advantage: fewer vendor relationships, a unified billing account, and observability tooling that works consistently across the stack.
For Antigravity users specifically, the investment in a detailed agents.md file compounds over time. Every constraint you document gets applied consistently across every file Antigravity touches. After two weeks of active development, the generated code quality for this stack is meaningfully better than what I got when I started with a blank context.
Your first step today: run npm create cloudflare@latest to scaffold a new project with the Hono template, then paste the agents.md excerpt from this guide into your project's agents.md. Prompt Antigravity with "set up a D1 database with Drizzle ORM and a user + subscription schema for a SaaS application." The scaffold you get back will already have the patterns from this guide applied.
Cloudflare's pace of development is fast — D1 performance improvements, Workers AI model additions, and Pages capabilities ship regularly. The Cloudflare Blog and the Cloudflare Workers Discord are the best places to stay current on what's changed.
For deeper coverage of Workers AI specifically, the article on Antigravity × Cloudflare Workers AI edge applications covers model selection, latency benchmarks, and the privacy case for on-device inference in more detail.
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.