Supabase Edge Functions
Runtime-agnostic i18n in Supabase Edge Functions (Deno)
Supabase Edge Functions run on Deno — Request, Headers, and fetch are Web Standards. No @better-i18n/server/node adapter needed; detectLocaleFromHeaders(req.headers) works directly.
File Structure
supabase/
functions/
_shared/
i18n.ts ← singleton (shared across all functions)
cors.ts ← CORS headers (includes accept-language)
send-notification/
index.ts
api/
index.tsEdge Function
Create the singleton
_shared/i18n.ts — import with npm: specifier for Deno:
import { createServerI18n } from "npm:@better-i18n/server@0.2.1";
export const i18n = createServerI18n({
project: Deno.env.get("BETTER_I18N_PROJECT") ?? "my-org/api",
defaultLocale: Deno.env.get("BETTER_I18N_DEFAULT_LOCALE") ?? "en",
});Define CORS headers
_shared/cors.ts — add accept-language to the allow list:
export const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, accept-language",
};If accept-language is not listed in Access-Control-Allow-Headers, browsers cannot send this header in cross-origin requests — locale detection will always fall back to defaultLocale.
Write the Edge Function
functions/send-notification/index.ts — req.headers is Web Standards Headers, no bridge needed:
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { i18n } from "../_shared/i18n.ts";
import { corsHeaders } from "../_shared/cors.ts";
Deno.serve(async (req: Request) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
// req.headers is Web Standards Headers — no bridge needed
const locale = await i18n.detectLocaleFromHeaders(req.headers);
const t = await i18n.getTranslator(locale);
const { userId } = await req.json();
const user = await fetchUser(userId);
if (!user) {
return new Response(
JSON.stringify({ error: t("errors.notFound") }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
await sendPushNotification({
userId,
title: t("notifications.welcome.title"),
body: t("notifications.welcome.body", { name: user.name }),
});
return new Response(
JSON.stringify({ sent: true, locale }),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
});For scheduled/cron functions invoked by pg_cron or the Supabase Scheduler, there is no browser request — read the user's preferred locale from the database instead.
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "npm:@supabase/supabase-js@2";
import { i18n } from "../_shared/i18n.ts";
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
Deno.serve(async (req: Request) => {
const { userId } = await req.json();
// Scheduled/cron invocation — no browser headers, read locale from DB
const { data: user } = await supabase
.from("users")
.select("name, language")
.eq("id", userId)
.single();
// DB stores ISO codes (tr, en, de, ar...) — matches manifest directly
const locale = user?.language ?? "en";
const t = await i18n.getTranslator(locale);
await sendPushNotification({
userId,
title: t("notifications.welcome.title"),
body: t("notifications.welcome.body", { name: user?.name }),
});
return new Response(JSON.stringify({ sent: true, locale }), {
headers: { "Content-Type": "application/json" },
});
});ISO codes stored in your DB (tr, en, de) map directly to better-i18n locales — no conversion needed.
Multiple Routes with Hono
For multiple routes, use Hono with Deno.serve(app.fetch):
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { Hono } from "npm:hono@4";
import { betterI18n } from "npm:@better-i18n/server@0.2.1/hono";
import type { Translator } from "npm:@better-i18n/server@0.2.1";
import { i18n } from "../_shared/i18n.ts";
import { corsHeaders } from "../_shared/cors.ts";
const app = new Hono<{ Variables: { locale: string; t: Translator } }>();
// CORS preflight + attach headers to all responses
app.use("*", async (c, next) => {
if (c.req.method === "OPTIONS") return c.text("ok", 200, corsHeaders);
await next();
Object.entries(corsHeaders).forEach(([k, v]) => c.res.headers.set(k, v));
});
app.use("*", betterI18n(i18n));
app.get("/users/:id", async (c) => {
const user = await db.findUser(c.req.param("id"));
if (!user) return c.json({ error: c.get("t")("errors.notFound") }, 404);
return c.json({ user, locale: c.get("locale") });
});
Deno.serve(app.fetch);Environment Variables
Set secrets via the Supabase CLI:
supabase secrets set BETTER_I18N_PROJECT=my-org/api
supabase secrets set BETTER_I18N_DEFAULT_LOCALE=enRead with Deno.env.get() — Supabase Edge Functions use Deno's env, not process.env.