DEV Community

kanta13jp1
kanta13jp1

Posted on

Scaling Supabase Edge Functions Past the 50-Function Cap: Hub-and-Action Architecture

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
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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},
);
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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'});
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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: 自分株式会社

buildinpublic #Flutter #Supabase #architecture

Top comments (0)