Three months ago, ReadyToRelease had its peak user count.
Then I broke everything with one line of code.
Here's the full story.
What is ReadyToRelease
An AI tool that generates market research reports in 90 seconds:
TAM/SAM/SOM, competitor analysis, SWOT, and implementation roadmap.
One-time $3 payment. No subscription.
Stack: Next.js 14 + Groq + Supabase + Stripe
The bug that caused 776,000 function invocations
In my reports page, I had this:
const res = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/generate-report?id=${params.id}`,
{ cache: 'no-store' }
);
This fetch was inside a Next.js Server Component with
cache: 'no-store'.
Every time someone visited a report page, this triggered:
- A full call to /api/generate-report
- Which launched 9 parallel scrapers inside Promise.allSettled()
- Which called Supabase, GitHub API, Reddit API, and 5 VC firm scrapers
- Every. Single. Visit.
Result: 776,000 Vercel function invocations in 3 weeks.
Vercel account paused. Product completely down.
The fix was removing 9 lines of code. The report data was already
being read directly from Supabase above: the fetch was completely
unnecessary.
The Supabase egress problem
At the same time, I had this in my dashboard:
const { data: reportsData } = await supabase
.from("reports")
.select(`
id, title, market_data, competitors,
trends, swot_analysis, financial_projections
`)
I was loading full JSONB fields (150KB+ each) for a list view
that only needed id, title, and created_at.
20 reports × 150KB = 3MB per dashboard visit.
Result: 19GB of Supabase egress on the free tier in 3 weeks.
Account restricted. 402 errors everywhere.
Fix: select only the fields you actually need.
const { data: reportsData } = await supabase
.from("reports")
.select("id, title, created_at, status, business_model")
The webhook that didn't exist
I built the entire Stripe payment flow but the webhook endpoint
file was in the wrong path.
My code pointed to: /api/stripe/webhook
My file was at: /api/stripe-webhook/route.ts
Every payment succeeded on Stripe.
Nothing was recorded in the database.
Users paid and got nothing.
Fix: stripe listen --forward-to localhost:3000/api/stripe-webhook
The .maybeSingle() that wasn't
After migrating to a new Supabase project, users who had
multiple rows in report_payments were getting blocked.
The query:
const { data: payment } = await supabaseAdmin
.from("report_payments")
.select("id")
.eq("user_id", user.id)
.maybeSingle() // fails silently with multiple rows
When maybeSingle() finds multiple rows it returns null.
So !payment was true and the endpoint returned 402.
Fix:
const { data: payments } = await supabaseAdmin
.from("report_payments")
.select("id")
.eq("user_id", user.id)
.limit(1)
if (!payments || payments.length === 0) {
return NextResponse.json({ error: "Payment required" },
{ status: 402 })
}
What I rebuilt in 3 months of silence
- Migrated to a new Supabase project (old one hit egress limits)
- Fixed all payment verification bugs
- Removed ReactFlow from bundle (it was imported but never used — dead code adding ~150kB)
- Added dynamic imports for heavy components
- Added rate limiting (max 3 reports/hour per user)
- Added skeleton loaders on dashboard
- Added /api/health endpoint + UptimeRobot monitoring
The unit economics
- AI cost per report: $0.0125 (Groq, 11 LLM calls)
- Price: $3
- Margin: 240x
- Infrastructure cost: $0/month (free tiers)
Where I am now
Product is back live: readytorelease.online
Demo (no signup needed): readytorelease.online/demo
0 paying customers. Relaunching today.
If you're building with Next.js + Supabase, the main lessons:
- Never use cache: 'no-store' in Server Components without understanding what it triggers
- Always select specific fields, never .select("*") on heavy tables
- Test your webhook path before going live
- .maybeSingle() returns null for multiple rows — use .limit(1) instead
Happy to answer questions about the stack or any of the bugs.
Top comments (0)