Using the database from a function

Read and write your SupaHost project's Postgres from an Edge Function with supabase-js, as the signed-in user (RLS applies) or as service_role, plus transactions via RPC.

Edge Functions on SupaHost reach the database through the Data API, using supabase-js. Functions don't get a SUPABASE_DB_URL, and Postgres isn't reachable over the network from outside the instance (only Business and Enterprise plans can connect directly, through an SSH tunnel). So skip Postgres drivers like postgres.js or pg in functions and use the client below.

That's rarely a limitation. Anything you'd do in one round trip with SQL, you can put in a Postgres function and call with rpc.

Two kinds of client

Client Key Row Level Security Use for
User client SUPABASE_ANON_KEY plus the caller's Authorization header Applies, as the signed-in user Anything done on behalf of a user
Admin client SUPABASE_SERVICE_ROLE_KEY Bypassed Webhooks, cron jobs, cross-user work

Prefer the user client. It means your RLS policies keep protecting data even if the function has a bug.

User client

import { createClient } from "jsr:@supabase/supabase-js@2";

Deno.serve(async (req) => {
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    { global: { headers: { Authorization: req.headers.get("Authorization") ?? "" } } },
  );

  // Only returns this user's rows, if the table has a policy such as
  // create policy "Own recipes" on recipes for select to authenticated
  //   using ((select auth.uid()) = user_id);
  const { data, error } = await supabase
    .from("recipes")
    .select("id, title, tags")
    .contains("tags", ["vegetarian"])
    .limit(20);

  if (error) return Response.json({ error: error.message }, { status: 400 });
  return Response.json(data);
});

Create the user client inside the handler, because the header changes with each request.

Admin client

import { createClient } from "jsr:@supabase/supabase-js@2";

// Module scope: created once per worker and reused across requests.
const admin = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
  { auth: { persistSession: false, autoRefreshToken: false } },
);

Deno.serve(async () => {
  const cutoff = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString();
  const { count, error } = await admin
    .from("draft_recipes")
    .delete({ count: "exact" })
    .lt("updated_at", cutoff);

  if (error) return Response.json({ error: error.message }, { status: 500 });
  return Response.json({ deleted: count });
});

The admin client ignores RLS completely. Pair it with Require a JWT plus a role check, or a webhook signature, so random callers can't use it (see Authentication and JWT verification). The admin client can also call admin.auth.admin.* to manage users; see Managing users (admin API).

Several writes in one transaction

Each supabase-js call is its own HTTP request and its own transaction. When writes must succeed or fail together, move them into a Postgres function and call it with rpc. Create it with a migration or Studio's SQL editor:

create or replace function public.book_table(p_restaurant_id bigint, p_slot timestamptz, p_guests int)
returns bigint
language plpgsql
security invoker
set search_path = ''
as $$
declare
  v_booking_id bigint;
begin
  update public.slots
     set seats_left = seats_left - p_guests
   where restaurant_id = p_restaurant_id
     and starts_at = p_slot
     and seats_left >= p_guests;

  if not found then
    raise exception 'Not enough seats left';
  end if;

  insert into public.bookings (restaurant_id, starts_at, guests, user_id)
  values (p_restaurant_id, p_slot, p_guests, (select auth.uid()))
  returning id into v_booking_id;

  return v_booking_id;
end;
$$;
const { data: bookingId, error } = await supabase.rpc("book_table", {
  p_restaurant_id: 12,
  p_slot: "2026-11-20T19:30:00Z",
  p_guests: 4,
});
if (error) return Response.json({ error: error.message }, { status: 409 });

The function runs as the caller (security invoker), so RLS still applies to both statements. More in Calling database functions (RPC) and Database functions.

Things to know about the Data API

  • Exposed schemas are fixed: public, storage, graphql_public and pgmq_public. Tables in other schemas need a view or function in public. See Exposed schemas.
  • At most 1000 rows per request. Page through bigger results with .range(from, to).
  • New tables and columns can take a moment to show up while PostgREST reloads its schema cache. See PostgREST not seeing new tables.
  • Types: generate TypeScript types and pass them to createClient<Database>() for typed queries. See Generating TypeScript types.

Queues from a function

If you've enabled Queues on the project, the admin client can use the pgmq_public wrappers, which service_role is allowed to execute:

await admin.schema("pgmq_public").rpc("send", {
  queue_name: "thumbnails",
  message: { recipeId: 812 },
});

See Processing queue messages for the consumer side.

Storage and Realtime

The same clients work for Storage (supabase.storage.from("photos"), subject to Storage policies on the user client) and for sending Realtime broadcasts. See Uploading files and Broadcast.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us