Better I18NBetter I18N
Server SDK

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.ts

Edge Function

Create the singleton

_shared/i18n.ts — import with npm: specifier for Deno:

supabase/functions/_shared/i18n.ts
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:

supabase/functions/_shared/cors.ts
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.tsreq.headers is Web Standards Headers, no bridge needed:

supabase/functions/send-notification/index.ts
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.

supabase/functions/send-notification/index.ts
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):

supabase/functions/api/index.ts
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=en

Read with Deno.env.get() — Supabase Edge Functions use Deno's env, not process.env.

On this page