# 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

<Tabs items={["From HTTP headers", "From database"]}>

<Tab value="From HTTP headers">

<Steps>

<Step>
### Create the singleton

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

```ts title="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",
});
```

</Step>

<Step>
### Define CORS headers

`_shared/cors.ts` — add `accept-language` to the allow list:

```ts title="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",
};
```

<Callout type="warn">
  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`.
</Callout>

</Step>

<Step>
### Write the Edge Function

`functions/send-notification/index.ts` — `req.headers` is Web Standards `Headers`, no bridge needed:

```ts title="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" } }
  );
});
```

</Step>

</Steps>

</Tab>

<Tab value="From database">

<Callout type="info">
  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.
</Callout>

```ts title="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" },
  });
});
```

<Callout type="info">
  ISO codes stored in your DB (`tr`, `en`, `de`) map directly to better-i18n locales — no conversion needed.
</Callout>

</Tab>

</Tabs>

## Multiple Routes with Hono

For multiple routes, use Hono with `Deno.serve(app.fetch)`:

```ts title="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:

```bash
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`.

## Related

<Cards>
  <Card title="Getting Started" icon="BookOpen" href="/frameworks/server-sdk">
    Singleton setup and basic usage.
  </Card>
  <Card title="Hono" icon="Zap" href="/frameworks/server-sdk/hono">
    Hono middleware with full TypeScript support.
  </Card>
  <Card title="API Reference" icon="FileCode" href="/frameworks/server-sdk/api-reference">
    `createServerI18n`, `ServerI18n` and all exports.
  </Card>
</Cards>