DEV Community

Cover image for Surviving Vercel's Free Tier: 12 Serverless Functions, Zero Waste
Benji Darby
Benji Darby

Posted on

Surviving Vercel's Free Tier: 12 Serverless Functions, Zero Waste

The Vercel Hobby plan gives you 12 serverless functions. That sounds like plenty until you're six months into building a SaaS and you start counting.

At IssueCapture — a drop-in widget that sends bug reports directly into Jira — we hit this wall faster than expected. Account management, API keys, analytics, Jira OAuth, payments, AI processing, widget submission, system utilities, admin operations. That's already eight distinct domains before you've split anything out.

We're currently sitting at 9 of 12 used. Getting there without burning through the limit required being deliberate about how we structure the API layer.

Why the Limit Bites You

Vercel counts any file in api/ as a serverless function. The instinct is to create a new file for each endpoint — it's clean, it's what every tutorial shows. But on the Hobby plan, that habit will strand you at the ceiling right when your product is getting interesting.

The good news: Vercel only counts top-level api/*.ts files.

Pattern 1: Action-Based Routing

Instead of api/team.ts, api/invites.ts, api/notifications.ts, consolidate into a single endpoint and route by ?action=:

// api/admin.ts
export default async function handler(req, res) {
  const { action } = req.query;

  const user = await getAuthenticatedUser(req);
  if (!user) return res.status(401).json({ error: 'Unauthorized' });

  switch (action) {
    case 'team-members-list':
      return handleListMembers(req, res, user, supabaseAdmin);
    case 'team-members-update-role':
      return handleUpdateMemberRole(req, res, user, supabaseAdmin);
    case 'team-invites-send':
      return handleSendInvite(req, res, user, supabaseAdmin);
    case 'notifications-list':
      return handleListNotifications(req, res, user, supabaseAdmin);
    case 'audit-logs-list':
      return handleListAuditLogs(req, res, user, supabaseAdmin);
    default:
      return res.status(400).json({ error: 'Invalid action' });
  }
}
Enter fullscreen mode Exit fullscreen mode

Our api/admin.ts handles 27 distinct actions — team management, domain allowlisting, compliance exports, audit logs, notification CRUD, Jira field lookups, AI theme generation, user suspension, queued submission management. One file, one slot.

Pattern 2: The _lib/ Directory

Vercel does not count files in api/_lib/ as serverless functions. This is where you put shared code and handler modules:

api/
  _lib/
    supabase.ts
    sentry.ts
    db-rate-limit.ts
    handlers/
      compliance.ts
      jira-fields.ts
      theme-generator.ts
      ... (17 handler files total)
  admin.ts   ← routes to all of the above
  submit.ts
Enter fullscreen mode Exit fullscreen mode

Handler files are plain TypeScript modules that export functions. They don't register routes, don't deploy independently, don't count toward the limit. The top-level files do the routing.

Pattern 3: .vercelignore for Dev-Only Endpoints

Local test endpoints you don't want in production:

# .vercelignore
api/test-emails.ts
api/test-purchase-email.ts
Enter fullscreen mode Exit fullscreen mode

They work locally via vercel dev, never reach production, never consume a slot.

How we organize the client side

On the dashboard, API calls use a consistent pattern:

async function adminAction(action: string, body?: object) {
  const response = await fetch(`/api/admin?action=${action}`, {
    method: body ? 'POST' : 'GET',
    headers: { 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
  return response.json();
}

// Usage
const members = await adminAction('team-members-list');
await adminAction('team-invites-send', { email: 'new@member.com', role: 'member' });
Enter fullscreen mode Exit fullscreen mode

One wrapper function, one endpoint, many actions. The abstraction is thin enough that you can see exactly what's happening in the network tab.

The trade-offs

It's less REST-pure. GET /api/admin?action=team-members-list is not as clean as GET /api/team/members. For a public API, that matters. For an internal dashboard API consumed only by your own frontend, it generally doesn't.

Debugging is slightly harder. When everything goes through one endpoint, your Vercel logs show /api/admin for 27 different operations. We add the action name to Sentry breadcrumbs and log context to make this manageable.

Cold starts are shared. One large function has a longer cold start than 27 small ones. In practice, the admin endpoint stays warm because it handles most dashboard traffic.

Latency-sensitive endpoints stay separate. Our widget submission endpoint (api/submit.ts) is its own function because it's public-facing and needs to be as lean as possible. Don't consolidate everything — consolidate the internal stuff.

The math

9 production functions / 12 limit = 3 remaining

Those 9 functions serve:
- 27 admin actions
- 1 public submission endpoint
- 1 Jira OAuth flow
- 1 API key management
- 1 analytics endpoint
- 1 payment webhook
- 1 AI queue processor
- 1 account management
- 1 system utilities
Enter fullscreen mode Exit fullscreen mode

Without action-based routing, we'd need 30+ functions. With it, we're at 9 with room for 3 more.


If you're bootstrapping on Vercel's free tier, the function limit will hit you eventually. Plan for it early. The action-routing pattern is ugly compared to a file-per-route convention, but it works, and it lets you ship a real product without upgrading your plan.

If you're on Vercel Hobby and you've found a way around the function limit that isn't action routing, I'd like to know. There's an argument for middleware-based routing that I haven't fully explored.

Top comments (0)