A lightweight, type-safe framework for building Supabase Edge Functions with routing, middleware, and testing utilities.
import { cors, SupaEdgeApp } from "@supa-edge/framework";
const app = new SupaEdgeApp();
app.use(cors());
app.get("/hello", (ctx) => {
return ctx.respond.json({ message: "Hello from supa-edge!" });
});
app.serve();| Package | Description |
|---|---|
@supa-edge/framework |
Router, middleware, context with lazy Supabase client, response helpers |
@supa-edge/testing |
Mock utilities for testing edge functions without a running server |
// deno.json
{
"imports": {
"@supa-edge/framework": "jsr:@supa-edge/framework@^0.1.0",
"@supa-edge/testing": "jsr:@supa-edge/testing@^0.1.0"
}
}import { SupaEdgeApp } from "@supa-edge/framework";
const app = new SupaEdgeApp();
app.get("/", (ctx) => ctx.respond.json({ status: "ok" }));
app.get("/greet/:name", (ctx) => {
return ctx.respond.json({ message: `Hello, ${ctx.params.name}!` });
});
app.serve();import { auth, cors, HttpError, logger, SupaEdgeApp, validator } from "@supa-edge/framework";
import type { InferValidated, ValidatorOptions } from "@supa-edge/framework";
import { z } from "zod";
const app = new SupaEdgeApp();
app.use(cors());
app.use(logger());
app.use(auth());
// List
app.get("/todos", async (ctx) => {
const { data, error } = await ctx.supabase
.from("todos")
.select("*")
.order("created_at", { ascending: false });
if (error) throw HttpError.internal(error.message);
return ctx.respond.json(data);
});
// Create with validation
const createOpts = {
body: z.object({
title: z.string().min(1).max(255),
completed: z.boolean().optional().default(false),
}),
} satisfies ValidatorOptions;
app.post("/todos", validator(createOpts), async (ctx) => {
const { title, completed } = ctx.validated.body as InferValidated<typeof createOpts>["body"];
const { data, error } = await ctx.supabase
.from("todos")
.insert({ title, completed, user_id: ctx.user!.id })
.select()
.single();
if (error) throw HttpError.internal(error.message);
return ctx.respond.json(data, 201);
});
// Delete
app.delete("/todos/:id", async (ctx) => {
const { error } = await ctx.supabase
.from("todos")
.delete()
.eq("id", ctx.params.id);
if (error) throw HttpError.internal(error.message);
return ctx.respond.empty();
});
app.serve();Express/Hono-style routing with path parameters via the URLPattern API.
app.get("/users/:id", (ctx) => {
return ctx.respond.json({ id: ctx.params.id });
});
app.post("/users", async (ctx) => {
const body = await ctx.json();
return ctx.respond.json(body, 201);
});
// PUT, PATCH, DELETE also supportedThe /functions/v1/{function-name} prefix that Supabase adds to edge function URLs is automatically stripped -- routes are written without it.
HEAD requests automatically match GET routes and return headers without a body.
Every handler and middleware receives a Context object:
app.get("/example", async (ctx) => {
ctx.request; // Original Request
ctx.method; // "GET", "POST", etc.
ctx.url; // Parsed URL object
ctx.params; // { id: "123" } from /users/:id
ctx.state; // Extensible state bag for passing data between middleware
await ctx.json(); // Parse body as JSON (cached)
await ctx.text(); // Parse body as text (cached)
ctx.supabase; // Lazy Supabase client (user context, uses Authorization header)
ctx.supabaseAdmin; // Lazy Supabase admin client (service role key)
ctx.respond.json({ ok: true }); // 200 JSON
ctx.respond.json(data, 201); // 201 JSON
ctx.respond.text("hello"); // 200 plain text
ctx.respond.empty(); // 204 No Content
ctx.respond.redirect("/other"); // 302 redirect
ctx.respond.redirect("/other", 301); // 301 redirect
});The Supabase clients are created lazily on first access. ctx.supabase forwards the request's Authorization header, so queries run in the context of the authenticated user. ctx.supabaseAdmin uses the SUPABASE_SERVICE_ROLE_KEY for privileged operations.
Middleware uses the onion model (Koa/Hono-style). Each middleware calls next() to proceed, and can run code before and after:
app.use(async (ctx, next) => {
const start = performance.now();
const response = await next();
console.log(`${ctx.method} ${ctx.url.pathname} - ${performance.now() - start}ms`);
return response;
});Route-level middleware is passed as variadic arguments before the handler:
app.post("/todos", authMiddleware, validationMiddleware, handler);cors(options?) -- CORS with automatic OPTIONS preflight handling.
import { cors } from "@supa-edge/framework";
app.use(cors());
app.use(cors({
origin: ["https://myapp.com", "https://staging.myapp.com"],
credentials: true,
maxAge: 86400,
}));
// Function-based origin
app.use(cors({
origin: (o) => o.endsWith(".myapp.com"),
}));CORS headers are set on ctx.responseHeaders, so they're automatically included in all responses -- including error responses. When credentials: true with the default wildcard origin, the middleware reflects the request's Origin header instead of "*" (as required by the CORS spec).
auth(options?) -- JWT authentication via Supabase.
import { auth } from "@supa-edge/framework";
// Required auth (401 if missing/invalid)
app.use(auth());
// Optional auth (ctx.user is undefined if not authenticated)
app.use(auth({ optional: true }));Sets ctx.user with id, email, role, and any user_metadata from the JWT.
validator(options) -- Zod-based request validation.
import { validator } from "@supa-edge/framework";
import type { InferValidated, ValidatorOptions } from "@supa-edge/framework";
import { z } from "zod";
const opts = {
body: z.object({
title: z.string().min(1),
done: z.boolean().optional(),
}),
} satisfies ValidatorOptions;
app.post("/items", validator(opts), (ctx) => {
// Type-safe access via InferValidated
const { title, done } = ctx.validated.body as InferValidated<typeof opts>["body"];
// title: string, done: boolean | undefined
});Validates body, query, and params. Multi-value query parameters (?tag=a&tag=b) are collected into arrays. Returns 400 with Zod issue details on failure.
logger() -- Request/response logging.
import { logger } from "@supa-edge/framework";
app.use(logger());
// [supa-edge] GET /todos 200 (12.3ms)rateLimit(options?) -- In-memory rate limiting.
import { rateLimit } from "@supa-edge/framework";
app.use(rateLimit({ max: 100, windowMs: 60_000 }));
// Custom key function
app.use(rateLimit({
keyFn: (ctx) => ctx.user?.id ?? "anonymous",
}));Sets X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. Returns 429 when exceeded.
Note: Counters are stored in process memory. In Supabase Edge Functions, instances may be recycled between requests, making this best-effort. For strict rate limiting, use an external store (e.g., Redis or Supabase database).
Throw HttpError from handlers or middleware for structured error responses:
import { HttpError } from "@supa-edge/framework";
app.get("/todos/:id", async (ctx) => {
const { data, error } = await ctx.supabase
.from("todos")
.select("*")
.eq("id", ctx.params.id)
.single();
if (error) throw HttpError.notFound("Todo not found");
return ctx.respond.json(data);
});Errors are caught at the app level and serialized to JSON:
{ "error": "Todo not found", "status": 404 }Available factories: HttpError.badRequest(), .unauthorized(), .forbidden(), .notFound(), .methodNotAllowed(), .conflict(), .tooManyRequests(), .internal(). All accept an optional details argument.
Custom error handling:
const app = new SupaEdgeApp({
onError: (error, ctx) => {
// Custom error response
return ctx.respond.json({ message: "Something went wrong" }, 500);
},
});@supa-edge/testing provides utilities to test edge functions without a running server or real Supabase instance.
import { assertEquals } from "@std/assert";
import { auth, cors, HttpError, SupaEdgeApp, validator } from "@supa-edge/framework";
import type { Middleware } from "@supa-edge/framework";
import {
authHeaders,
createMockSupabase,
createMockUser,
mockEnv,
TestHandler,
} from "@supa-edge/testing";
function createTestApp() {
const mockSupabase = createMockSupabase()
.onAuth(createMockUser())
.onTable("todos", {
select: { data: [{ id: 1, title: "Test" }], error: null },
insert: { data: { id: 2, title: "New" }, error: null },
delete: { data: null, error: null },
})
.onRpc("get_count", { data: { count: 42 }, error: null })
.build();
// Inject mock supabase via middleware
const mockSupabaseMw: Middleware = async (ctx, next) => {
Object.defineProperty(ctx, "supabase", {
value: mockSupabase,
configurable: true,
});
return await next();
};
const app = new SupaEdgeApp();
app.use(cors());
app.use(mockSupabaseMw);
app.use(auth());
app.get("/todos", async (ctx) => {
const { data, error } = await ctx.supabase.from("todos").select("*");
if (error) throw HttpError.internal(error.message);
return ctx.respond.json(data);
});
return new TestHandler(app.handler);
}
Deno.test("GET /todos returns list", async () => {
const cleanup = mockEnv();
try {
const t = createTestApp();
const res = await t.get("/todos", { headers: authHeaders() });
assertEquals(res.status, 200);
assertEquals((await res.json()).length, 1);
} finally {
cleanup();
}
});
Deno.test("GET /todos requires auth", async () => {
const cleanup = mockEnv();
try {
const t = createTestApp();
const res = await t.get("/todos");
assertEquals(res.status, 401);
} finally {
cleanup();
}
});TestHandler -- Invokes the app handler directly without Deno.serve().
const t = new TestHandler(app.handler);
await t.get("/path");
await t.post("/path", { body: { key: "value" } });
await t.put("/path", { body: data, headers: { "X-Custom": "value" } });
await t.patch("/path", { body: partial });
await t.delete("/path");
await t.options("/path");
// Query parameters
await t.get("/search", { params: { q: "test", page: "1" } });mockEnv(overrides?) -- Stubs Deno.env with Supabase defaults. Returns a cleanup function.
const cleanup = mockEnv();
// SUPABASE_URL = "http://localhost:54321"
// SUPABASE_ANON_KEY = "test-anon-key"
// SUPABASE_SERVICE_ROLE_KEY = "test-service-role-key"
cleanup(); // restores original valuescreateMockSupabase() -- Builder-pattern mock Supabase client.
const supabase = createMockSupabase()
.onTable("todos", {
select: { data: [...], error: null },
insert: { data: {...}, error: null },
update: { data: {...}, error: null },
upsert: { data: {...}, error: null },
delete: { data: null, error: null },
})
.onRpc("my_function", { data: { result: 42 }, error: null })
.onAuth(createMockUser())
.build();Supports full Supabase query chaining (.eq(), .order(), .limit(), .single(), etc.).
authHeaders(token?) -- Creates { Authorization: "Bearer <token>" }.
createMockUser(overrides?) -- Creates a mock user object with sensible defaults.
mockEdgeRuntime() -- Mocks EdgeRuntime.waitUntil() for testing background tasks.
| Variable | Required | Description |
|---|---|---|
SUPABASE_URL |
Yes | Supabase project URL |
SUPABASE_ANON_KEY |
Yes | Supabase anonymous key |
SUPABASE_SERVICE_ROLE_KEY |
No | Required only if using ctx.supabaseAdmin |
# Type check
deno check packages/framework/mod.ts packages/testing/mod.ts
# Lint
deno lint packages/ examples/
# Format
deno fmt packages/ examples/
# Run all tests
deno test --allow-env --allow-net packages/
# Run example tests
deno test --allow-env --allow-net examples/
# Run a single test file
deno test --allow-env --allow-net packages/framework/tests/app_test.ts