It started innocently enough. "Let's build a portfolio of SaaS products," I said to my AI coding assistant. Seven days later, I had 22 fully-featured Next.js applications running on Vercel, all backed by Convex for real-time data. I also had a Vercel bill that made me reconsider my life choices, three mental breakdowns, and enough hard-won lessons to fill a book.
This is what I learned.
The Setup
Each app followed the same stack: Next.js 16 with the App Router, Convex for the database and real-time subscriptions, Clerk for authentication, and Tailwind CSS v4 for styling. Deploy to Vercel. Repeat 22 times.
Sounds simple. It was not.
Lesson 1: Vercel Fluid Compute Will Bankrupt You (Unless You Do This)
Let me tell you about the day I opened my Vercel dashboard and saw a bill projection that looked like a phone number. Not a local one—an international one with country code.
What Fluid Compute Is and Why It's Brilliant
Vercel's Fluid Compute is genuinely revolutionary. Instead of the old model where your serverless functions spin up, run for a fixed duration, and spin down—Fluid Compute lets functions run exactly as long as they need. No cold starts. No artificial timeouts. Your function starts, does its work, and stops. You pay for exactly what you use.
Beautiful, right? Here's the trap.
The Cost Trap: Every Async Server Component = Compute Time
In the Next.js App Router, every async server component is a function that runs on Vercel's infrastructure. Every. Single. One.
// This innocent-looking component costs money every time someone visits
export default async function LocationPage({ params }: { params: { slug: string } }) {
const location = await db.query.locations.findFirst({
where: eq(locations.slug, params.slug)
});
const services = await db.query.services.findMany({
where: eq(services.locationId, location.id)
});
const reviews = await db.query.reviews.findMany({
where: eq(reviews.locationId, location.id),
orderBy: desc(reviews.createdAt),
limit: 10
});
return <LocationDetail location={location} services={services} reviews={reviews} />;
}
With 22 apps, each having 20+ location pages, and organic traffic starting to flow—my compute usage was exponential. Every page view meant three database queries running on Vercel's servers. Fluid Compute was doing exactly what it promised: running as long as needed. And I was paying for every millisecond.
DEEP DIVE: Cost Optimization Strategies
Here's everything I learned about not going bankrupt while using Vercel Fluid Compute.
Strategy 1: Static vs Dynamic — Know When to Use Each
The single most important decision in Next.js App Router is whether a page should be static or dynamic.
Use Static (Default) when:
- Content doesn't change frequently (blog posts, landing pages, location pages)
- Content is the same for all users (no personalization)
- You can accept data being slightly stale
Use Dynamic when:
- Content changes per-user (dashboards, authenticated pages)
- Content must be real-time (live feeds, stock prices)
- You're handling user input (search results, filtered data)
// Static by default — no cost per request!
export default function AboutPage() {
return <div>This page is static. Free after build.</div>;
}
// Dynamic — costs money every request
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const user = await getCurrentUser();
return <Dashboard user={user} />;
}
Strategy 2: export const dynamic = "force-static" Is Your Best Friend
When you have async data fetching but the data doesn't change often, force the page to be static:
// Force this page to be static despite having async data
export const dynamic = "force-static";
export default async function LocationPage({ params }: { params: { slug: string } }) {
// This runs at BUILD TIME, not request time
const location = await db.query.locations.findFirst({
where: eq(locations.slug, params.slug)
});
return <LocationDetail location={location} />;
}
// Generate all location pages at build time
export async function generateStaticParams() {
const locations = await db.query.locations.findMany();
return locations.map((loc) => ({ slug: loc.slug }));
}
This fetches data once at build time, not on every request. Zero compute cost per visitor.
Strategy 3: ISR with revalidate for Best of Both Worlds
Incremental Static Regeneration lets you have static pages that update periodically:
// Revalidate every hour — static between revalidations
export const revalidate = 3600;
export default async function LocationPage({ params }: { params: { slug: string } }) {
const location = await getLocation(params.slug);
const reviews = await getReviews(location.id);
return <LocationDetail location={location} reviews={reviews} />;
}
You can also trigger on-demand revalidation when content changes:
// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
export async function POST(request: Request) {
const { path, secret } = await request.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: "Invalid secret" }, { status: 401 });
}
revalidatePath(path);
return Response.json({ revalidated: true });
}
Strategy 4: Move Data Fetching to Build Time
Instead of fetching in components, fetch during generateStaticParams or generateMetadata:
// ❌ BAD: Fetches on every request
export default async function Page({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return <Article post={post} />;
}
// ✅ GOOD: Fetches once at build time
export const dynamic = "force-static";
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug); // Cached during build
return {
title: post.title,
description: post.excerpt,
};
}
export default async function Page({ params }: { params: { slug: string } }) {
// Uses cached data from generateMetadata
const post = await getPost(params.slug);
return <Article post={post} />;
}
Strategy 5: Client Components for Interactivity (No Server Cost)
Interactive elements don't need server rendering. Move them client-side:
// Static server component — renders once at build
export default function LocationPage({ location }) {
return (
<div>
<h1>{location.name}</h1>
<p>{location.description}</p>
{/* Interactive parts are client components */}
<ContactForm locationId={location.id} />
<ReviewCarousel initialReviews={location.reviews} />
<MapEmbed coordinates={location.coordinates} />
</div>
);
}
// components/ContactForm.tsx
"use client";
export function ContactForm({ locationId }: { locationId: string }) {
const [formData, setFormData] = useState({});
// All this interactivity runs in the browser — free!
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
await submitForm(locationId, formData);
};
return <form onSubmit={handleSubmit}>...</form>;
}
The server component renders the static shell. The client component handles all interactivity. Zero compute cost for user interactions.
Strategy 6: Suspense Boundaries to Parallelize
If you must fetch on the server, parallelize with Suspense:
// ❌ BAD: Sequential fetching (slow + expensive)
export default async function Page() {
const user = await getUser(); // 100ms
const posts = await getPosts(); // 200ms
const comments = await getComments(); // 150ms
// Total: 450ms of compute time
return <Dashboard user={user} posts={posts} comments={comments} />;
}
// ✅ GOOD: Parallel fetching with Suspense
export default function Page() {
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserSection />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<PostsSection />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<CommentsSection />
</Suspense>
</div>
);
}
// Even better: Promise.all for truly parallel
export default async function Page() {
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments(),
]);
// Total: 200ms (slowest query) instead of 450ms
return <Dashboard user={user} posts={posts} comments={comments} />;
}
Strategy 7: Edge vs Serverless Function Selection
Edge functions are cheaper and faster for simple operations:
// Use Edge for simple, fast operations
export const runtime = "edge";
export async function GET(request: Request) {
// Edge is great for:
// - Redirects
// - A/B testing
// - Geolocation
// - Simple API responses
const country = request.headers.get("x-vercel-ip-country");
return Response.json({ country });
}
// Use Serverless (Node.js) for complex operations
export const runtime = "nodejs";
export async function POST(request: Request) {
// Serverless is needed for:
// - Database connections (Prisma, etc.)
// - Heavy computation
// - Node.js-specific APIs
const data = await heavyDatabaseOperation();
return Response.json(data);
}
Strategy 8: Caching Strategies (The Holy Trinity)
Next.js has three cache layers. Master all of them:
1. Fetch Cache (Data Cache)
// Cache this fetch for 1 hour
const data = await fetch("https://api.example.com/data", {
next: { revalidate: 3600 }
});
// Cache forever until manually revalidated
const data = await fetch("https://api.example.com/data", {
next: { tags: ["my-data"] }
});
// Never cache (real-time data)
const data = await fetch("https://api.example.com/data", {
cache: "no-store"
});
2. Full Route Cache
// Static pages are cached at build time
export const dynamic = "force-static";
// ISR pages are cached until revalidation
export const revalidate = 3600;
// Dynamic pages are never route-cached
export const dynamic = "force-dynamic";
3. Router Cache (Client-side)
// Prefetch links for instant navigation
import Link from "next/link";
// Prefetches automatically on hover/viewport
<Link href="/about">About</Link>
// Disable prefetching for rarely-visited pages
<Link href="/terms" prefetch={false}>Terms</Link>
SEO-First Compliance
Static pages aren't just cheaper—they're better for SEO.
Why Static Wins for SEO
- Faster TTFB: No server computation means instant response
- Consistent performance: No cold starts, no variance
- Better Core Web Vitals: LCP improves dramatically
- Crawl budget efficiency: Googlebot can crawl more pages faster
Metadata Exports Done Right
// Static metadata
export const metadata: Metadata = {
title: "My Page Title",
description: "My page description",
openGraph: {
title: "My Page Title",
description: "My page description",
images: ["/og-image.png"],
},
};
// Dynamic metadata (still cached during build for static pages)
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.ogImage],
type: "article",
publishedTime: post.publishedAt,
authors: [post.author.name],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
images: [post.ogImage],
},
};
}
Sitemap and robots.txt
// app/sitemap.ts
import { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
const locations = await getAllLocations();
return [
{ url: "https://example.com", lastModified: new Date(), priority: 1 },
{ url: "https://example.com/about", lastModified: new Date(), priority: 0.8 },
...posts.map((post) => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: post.updatedAt,
priority: 0.6,
})),
...locations.map((loc) => ({
url: `https://example.com/locations/${loc.slug}`,
lastModified: loc.updatedAt,
priority: 0.7,
})),
];
}
// app/robots.ts
import { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/api/", "/admin/"],
},
sitemap: "https://example.com/sitemap.xml",
};
}
The Refactor Playbook
Here's exactly how I fixed my 22 apps.
Step 1: Audit — Find All Server Components with Data Fetching
# Find all async server components
grep -r "export default async function" app/ --include="*.tsx" | grep -v "use client"
# Find all direct database calls
grep -r "await db\." app/ --include="*.tsx"
grep -r "await fetch" app/ --include="*.tsx"
Step 2: Classify — Which MUST Be Dynamic?
Create a spreadsheet:
| Page | Has Auth? | Real-time? | User-specific? | Verdict | |------|-----------|------------|----------------|--------| | /locations/[slug] | No | No | No | STATIC | | /dashboard | Yes | Yes | Yes | DYNAMIC | | /blog/[slug] | No | No | No | STATIC | | /search | No | No | Yes (query) | DYNAMIC |
Step 3: Convert — The Step-by-Step Process
For each page that should be static:
- Add
export const dynamic = "force-static"; - Add
generateStaticParams()if it's a dynamic route - Move interactive elements to client components
- Add
export const revalidate = X;if data changes periodically
// Before: Dynamic (expensive)
export default async function LocationPage({ params }) {
const location = await getLocation(params.slug);
return <LocationDetail location={location} />;
}
// After: Static (free after build)
export const dynamic = "force-static";
export const revalidate = 86400; // Daily
export async function generateStaticParams() {
const locations = await getAllLocations();
return locations.map((loc) => ({ slug: loc.slug }));
}
export default async function LocationPage({ params }) {
const location = await getLocation(params.slug);
return <LocationDetail location={location} />;
}
Step 4: Verify — Check Vercel Dashboard
After deploying:
- Go to Vercel Dashboard → Project → Analytics
- Check "Function Invocations" — should drop dramatically
- Check "Function Duration" — remaining functions should be faster
- Check "Edge Requests" vs "Serverless Requests"
Real Numbers: Before/After Cost Comparison
Here's what happened to my bill:
| Metric | Before | After | Change | |--------|--------|-------|--------| | Function Invocations | 847,000/mo | 23,000/mo | -97% | | Function Duration | 412 GB-hours | 8 GB-hours | -98% | | Estimated Monthly Cost | $2,847 | $47 | -98% |
Six projects got completely refactored. 180 dynamic pages became static. My bill went from "maybe I should get a real job" to "this is fine."
The lesson: Fluid Compute is amazing, but only if you're intentional about what runs on the server.
Lesson 2: Multi-Agent Chaos is Real
Here's what happens when you let 29 AI coding agents work in parallel on the same codebase: they produce 50,000 lines of code that doesn't compile.
I watched in horror as agents created duplicate database tables, missed Convex initialization, introduced type mismatches, and generally produced what can only be described as a dumpster fire in TypeScript form.
The fix? I killed all 29 agents and had a single agent with full context rewrite the entire Convex backend. It took 11 minutes. Single agent with full context beats many agents with partial context, every time.
The lesson isn't that AI can't help—it absolutely can. The lesson is that coordination costs are real, and giving one brain the whole picture beats giving 29 brains each a puzzle piece.
Lesson 3: Copy Your Damn API Keys
I spent two hours debugging why Clerk authentication wasn't working. The error messages were cryptic. The tokens looked right. Everything seemed fine.
Turns out I had transcribed CLERK_SECRET_KEY from the Clerk dashboard by looking at the screen and typing what I saw. The key contained 4l (four + lowercase L) and I typed 41 (four + one). It also had X and I typed x.
Always click the copy button. Never transcribe API keys visually. Fonts are liars.
This happened to me twice before I learned.
Lesson 4: CSS Filter Hacks Are For Cowards
I tried to implement dark mode using filter: invert(1) hue-rotate(180deg). It worked! Images looked weird, but hey, it was fast.
Then I showed it to someone. Their exact words: "We don't do lazy at HurleyUS. I'd rather wait 3 days for a real solution than ship garbage in 3 minutes."
They were right. The filter hack inverted images, made gradients look wrong, and broke any component with specific color intentions. I spawned four sub-agents to properly update all 25 variants with real dark: Tailwind classes.
Do the hard work. If there are 25 files that need updating, update 25 files properly.
Lesson 5: Convex Schema Design Matters More Than You Think
Every one of these 22 apps needed real-time data. Convex handles this beautifully—you write a query, and it automatically subscribes to updates. Magic.
But magic has rules.
Indexes matter. If you're querying by slug, you need a .index("by_slug", ["slug"]) or your queries will scan the entire table. With 22 apps hitting the same Convex deployment, inefficient queries compound fast.
Schema unions (v.union()) are powerful but can make migrations painful. Decide early if a field should accept multiple types or if you need separate fields.
And please, for the love of god, don't store URLs in fields meant for storage IDs. I had to write a migration that handles both because past-me made poor choices.
Lesson 6: Environment Variables Are a Lifestyle
With 22 apps, I had roughly 200 environment variables to manage. Each app needed:
NEXT_PUBLIC_CONVEX_URLCONVEX_DEPLOY_KEYNEXT_PUBLIC_CLERK_PUBLISHABLE_KEYCLERK_SECRET_KEYRESEND_API_KEYNEXT_PUBLIC_POSTHOG_KEYSENTRY_DSN- Plus app-specific variables
I created a shared .env.shared file for the common ones. I standardized on a single Resend sender (notify@uncap.us) across all projects. I documented every credential in a secure note.
Still lost track of which app had which Stripe keys. Still haven't fixed that.
Lesson 7: Next.js 16 Broke All Your Middleware
Next.js 16 deprecated middleware.ts and renamed it to proxy.ts. If you're using Clerk or any other middleware-based auth, you need to rename the file or run the codemod:
npx @next/codemod@latest middleware-to-proxy
I found this out after deploying 8 apps with broken authentication.
Lesson 8: Architecture Before Agents
The apps that went smoothly were the ones where I wrote an architecture document first. Clear database schema. Defined API boundaries. Known component hierarchy.
The apps that were nightmares were the ones where I said "just figure it out" to the AI and let it improvise.
Complexity is easy. Simplicity requires understanding. If you don't understand your own system architecture, no amount of AI assistance will save you.
Lesson 9: The 22-App Stack
After all this, here's what I'd recommend for anyone trying to build a portfolio of modern web apps:
- Framework: Next.js 16+ (App Router, PPR, React 19)
- Database: Convex (real-time, reactive, amazing DX)
- Auth: Clerk (handles the annoying stuff)
- Payments: Stripe (when you need it)
- Analytics: PostHog (privacy-friendly, powerful)
- Errors: Sentry (catches what you miss)
- Email: Resend (actually delivers)
- Hosting: Vercel (but watch your compute usage)
This stack deploys fast, scales well, and doesn't require you to think about infrastructure.
Lesson 10: Ship Anyway
22 apps in 7 days. Most work. Some are buggy. A few are genuinely good.
The goal was never perfection—it was learning what it takes to ship at scale. I now know more about Convex schema design, Vercel cost optimization, Next.js 16 migrations, and multi-agent coordination than I did a week ago.
I also know that 4l and 41 are not the same thing.
If you're building with Convex, Next.js, or thinking about deploying at scale—I've made the mistakes so you don't have to. Reach out if you want to chat about any of this.