Scaling Supabase Edge Functions Past the 50-Function Cap: Hub-and-Action Architecture
The Problem
Supabase's free and Pro tiers have a hard cap of 50 Edge Functions per project. When building a full-stack app that replaces 21 competitors (notes, tasks, finance, scheduling, AI, horse racing, real estate...), you hit that cap fast.
θͺεζ ͺεΌδΌη€Ύ peaked at 99 deployed Edge Functions before the cap was enforced. Then: 402 errors on every new deployment. The solution wasn't deleting features β it was restructuring.
The Hub Pattern
One EF can route to many independent action handlers. Instead of 50 separate EFs, you get 1 hub EF with 50+ action routes:
// tools-hub/index.ts
const body = await req.json();
const { action, ...rest } = body;
switch (action) {
case 'horseracing.today': return getHorseRacingToday(supabase);
case 'horseracing.predict_all': return predictAllRaces(supabase, rest);
case 'realestate.search': return searchProperties(supabase, rest);
case 'realestate.estimate': return estimatePrice(supabase, rest);
case 'guitar.analyze': return analyzeTab(supabase, rest);
// ... 20 more actions
}
One cold start, one CORS config, one deploy step. Zero new EF slots consumed for each new action.
Hub Inventory
Final state: 16 EFs total, all feature-complete:
| Hub EF | Domain | Actions |
|---|---|---|
core-hub |
User management, profile, settings | ~8 actions |
growth-hub |
Analytics, CVR, referrals | ~6 actions |
ai-hub |
Gemini, Claude, GPT routing | ~10 actions |
tools-hub |
Horse racing, guitar, real estate | ~12 actions |
lifestyle-hub |
Health, finance, calendar | ~8 actions |
schedule-hub |
Cron jobs, reminders | ~5 actions |
| Standalone Γ5 |
ai-assistant, get-home-dashboard, etc. |
1 action each |
99 β 16. Every feature still works.
Migration: Old EF β Hub Action
The steps to absorb a standalone EF into a hub:
Step 1: Add the action handler
// In tools-hub/index.ts
case 'guitar.analyze':
return analyzeGuitarTab(supabase, body);
Copy the logic from the old EF's handler into the new function.
Step 2: Update the Flutter caller
// Before: called standalone EF
final response = await _supabase.functions.invoke('guitar-recording-studio');
// After: calls hub with action param
final response = await _supabase.functions.invoke(
'tools-hub',
body: {'action': 'guitar.analyze', ...params},
);
Step 3: Update CORS handling
The hub needs to handle CORS once, at the top:
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, content-type',
};
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders, status: 200 });
}
Every action response includes these headers β no per-action CORS config needed.
Step 4: Delete the old EF from deploy-prod.yml
# deploy-prod.yml β remove the old standalone entry
functions:
- guitar-recording-studio # β delete this line
- tools-hub # β hub already listed
The old EF still exists in supabase/functions/ as dead code but is no longer deployed. Delete it if you're tidying up.
NO_AUTH Zone Pattern
Some hub actions are called by GitHub Actions cron jobs β no user JWT available. Adding a bypass list keeps cron working without opening the whole hub:
const NO_AUTH_ACTIONS = [
'horseracing.today', // public race data fetch
'horseracing.predictions', // public prediction GET
];
if (!NO_AUTH_ACTIONS.includes(action)) {
// validate JWT here
const { data: { user }, error } = await supabase.auth.getUser(token);
if (error || !user) return new Response('Unauthorized', { status: 401 });
}
Actions in NO_AUTH_ACTIONS bypass JWT validation but still require the Supabase service key at the API gateway level. Good balance of security and automation.
Dart: Centralized Hub Caller
To avoid scattering 'tools-hub' strings across 20 files, a thin wrapper:
class ToolsHub {
static final _supabase = Supabase.instance.client;
static Future<Map<String, dynamic>> call(
String action, {
Map<String, dynamic> params = const {},
}) async {
final response = await _supabase.functions.invoke(
'tools-hub',
body: {'action': action, ...params},
);
if (response.status != 200) throw Exception('Hub error: ${response.status}');
return response.data as Map<String, dynamic>;
}
}
// Usage:
final data = await ToolsHub.call('horseracing.today');
final result = await ToolsHub.call('realestate.search', params: {'city': 'Tokyo'});
One change if the hub EF name ever changes. Type-safe with a params map.
What the Hub Pattern Doesn't Solve
Execution time: Hub actions share the 30-second Deno timeout. Long-running operations (bulk AI calls, large file processing) need their own EF slot or an async queue pattern.
Error isolation: A bug in one action's import can crash the entire hub. Keep handlers in separate files and import them:
// tools-hub/handlers/horseracing.ts
export async function getHorseRacingToday(supabase: SupabaseClient) { ... }
// tools-hub/index.ts
import { getHorseRacingToday } from './handlers/horseracing.ts';
Bundle size: Deno's cold start time scales with bundle size. If 20 handlers import 20 different libraries, cold start gets slow. Group actions by shared dependencies.
Summary
| Problem | Solution |
|---|---|
| 50 EF hard cap | Hub-and-action routing (1 EF, many actions) |
| GitHub Actions cron (no JWT) |
NO_AUTH_ACTIONS bypass list |
| 20 Dart callers with different EF names |
ToolsHub.call(action, params) wrapper |
| Long-running actions timeout | Keep those on standalone EF slots |
99 deployed EFs β 16. Features: unchanged.
Try it: θͺεζ ͺεΌδΌη€Ύ









