supabase.
supabase11 min read

Supabase Edge Functions vs Next.js Server Actions: When to Reach for Which

Supabase Edge Functions vs Next.js Server Actions: When to Reach for Which

A few months ago I was reviewing PRs on a small Supabase and Next.js side project, and I noticed the same three-line form submit had been implemented three different ways in three different files. One version was a Server Action. One was an Edge Function. One was a hand-rolled API route. All three worked, all three had passed review, and nobody on the team could quite explain why we had picked one path over another in each case.

That kind of drift is how codebases get expensive. This piece is a pointer, not a tutorial. It's the short mental model I wish I'd handed that team, sharp enough to defend in code review and boring enough that you'll actually use it. By the end you should be able to look at a new feature request and say, in a single sentence, whether it belongs in an Edge Function or a Server Action, and defend the answer without opening a browser tab. We'll walk through the runtime each one uses, the security model each one assumes, the latency profile each one exhibits, and the operational surface each one exposes.

The two primitives, briefly

When your app calls the server, what actually runs? A container, a V8 isolate, or your own long-lived Node process? A Supabase Edge Function is the first of those answers: a TypeScript or JavaScript module that runs on Deno, deployed to a globally distributed edge network operated by Supabase. It's invoked over HTTPS with an Authorization header, and it exposes a request/response contract. Because it's a plain HTTP endpoint, anything that speaks HTTP can call it: your Next.js app, your React Native app, a webhook from Stripe, a cron job from GitHub Actions, a curl command from your terminal. It has access to the Supabase project's environment variables, its service role key, and it sits inside the same network perimeter as your Postgres database. Cold starts are measured in tens of milliseconds because the Deno isolate model is closer to a V8 isolate than to a container.

A Next.js Server Action is an async function annotated with the use server directive that runs on your Next.js server (typically Node.js, sometimes the Edge Runtime), invoked by the client as if it were a local function. Next.js takes care of the wire format: it serializes arguments, generates the endpoint, wires up the CSRF-adjacent action ID, and hands the return value back to the client. You never write a fetch call, you never define a route, you never think about a URL. From the caller's perspective it's indistinguishable from a normal async function, which is the entire pedagogical point.

Those two paragraphs already suggest the axis of choice. Edge Functions are HTTP endpoints; Server Actions are RPC. Edge Functions are callable from anywhere; Server Actions are callable from your React tree. Edge Functions run on Deno at Supabase's edge; Server Actions run on your Next.js server wherever you deploy it. Everything else flows from those distinctions.

Runtime and cold-start behavior

The "edge" in Edge Functions sounds like it should always win a latency race, but a Server Action colocated with your database will beat it on wall-clock time in more setups than the marketing implies. That paradox starts with the runtime: Supabase Edge Functions use the Deno runtime, which means you get modern web standards baked in: fetch, Request, Response, streams, and a permissions model that's stricter than Node by default. Deploys are near-instant, and the isolate model keeps cold starts cheap. The edge network is globally distributed, so a user in Frankfurt hitting your function will land on a Frankfurt worker, and that worker may then reach across the ocean to hit your Postgres primary in us-east-1. This asymmetry matters: the compute is fast to reach, but the database round-trip is not free.

Next.js Server Actions run wherever your Next.js app runs. If you deploy to Vercel with the default Node.js runtime, they run in a serverless function. If you opt into the Edge Runtime, they run in a V8 isolate closer in spirit to a Deno worker. If you self-host with a long-running Node process, they run in that process with no cold start at all. Location matters: a Server Action running in a serverless function in the same region as your Postgres primary will finish a round-trip faster than an Edge Function that has to hop across regions, even though the Edge Function is "closer" to the user.

The lesson isn't that one runtime is faster than the other. It's that latency is a system-level property. The right question isn't "where does the code run" but "how many round trips does one user interaction cost, and where does each round trip physically travel." An Edge Function that streams a response from an LLM API will feel fast because the streaming hides the database hop. A Server Action that writes a row and returns the new state will feel fast because there's only one round trip and it stays inside your provider's network.

Security and auth models

Why does the same signed-in user look authenticated to a Server Action and anonymous to an Edge Function called on the very next line? One header is the whole difference: Edge Functions receive an Authorization header and are responsible for validating it. In the default Supabase pattern, the caller passes the user's JWT, and the function uses the Supabase JS client with that JWT to make queries; row-level security policies then enforce access at the database layer. This is the model to reach for when you want Postgres RLS to be the single source of truth for authorization. It also works cleanly for third-party callers: a webhook from Stripe includes a signature, your function verifies the signature, and no user identity is involved.

Server Actions run inside your Next.js app, so they inherit whatever session mechanism your app already uses. If you use @supabase/ssr with cookie-based sessions, the user's session is already available inside the action; you don't pass it explicitly. It's convenient, but it's also a subtle coupling: the Server Action assumes it's being called from a browser context with a valid session cookie, and that assumption breaks the moment you try to call the same logic from a background job or a mobile app. Next.js has been adding warnings about accidentally exposed Server Actions and closed-by-default action invocation, so read the current guidance before assuming any given version's defaults.

The security rule of thumb: if the code needs to be callable by things that aren't your Next.js app, it belongs in an Edge Function. If it only ever runs in response to a user interaction in your own React tree, a Server Action is fine, and probably nicer to write.

A concrete side-by-side

To make the shape difference concrete, here's the same operation written both ways: append a row to a notes table and return the inserted record.

// Supabase Edge Function: supabase/functions/create-note/index.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

Deno.serve(async (req) => {
  const authHeader = req.headers.get('Authorization') ?? '';
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_ANON_KEY')!,
    { global: { headers: { Authorization: authHeader } } },
  );
  const { body } = await req.json();
  const { data, error } = await supabase.from('notes').insert({ body }).select().single();
  if (error) return new Response(error.message, { status: 400 });
  return Response.json(data);
});
// Next.js Server Action: app/notes/actions.ts
'use server';
import { createClient } from '@/lib/supabase/server';

export async function createNote(body: string) {
  const supabase = createClient();
  const { data, error } = await supabase.from('notes').insert({ body }).select().single();
  if (error) throw new Error(error.message);
  return data;
}

Both do the same thing. The Edge Function is longer because it deals with the HTTP envelope explicitly. The Server Action is shorter because Next.js hides that envelope. The Server Action is only callable from a React component in the same app; the Edge Function is callable from anywhere. Neither one is "better" in isolation; the choice depends on what the second, third, and fourth callers of this operation will look like six months from now.

When to reach for an Edge Function

Reach for an Edge Function when any of the following is true. The operation needs to be callable from something that isn't a browser session in your Next.js app: a mobile app, a webhook, a scheduled job, another service. The operation needs to run on a schedule and there's no user around to trigger it; Supabase's scheduler talks to Edge Functions directly. The operation needs to stream a response, and you want the streaming to happen close to the user rather than through a hop back to your origin server. The operation is a long-running background task that you don't want tying up a Next.js render worker. The operation needs to run without a session at all, such as processing an incoming email or reacting to a database webhook.

Edge Functions also shine when the operation is a bounded piece of business logic that you want to version and deploy independently of your frontend. If your frontend and your "issue a refund" logic ship on the same deployment pipeline, a bug in your marketing site can delay a fix in your billing code. Extracting the refund path into an Edge Function decouples the two release cadences.

When to reach for a Server Action

Reach for a Server Action when the operation is a direct response to a user interaction in your React tree, and no other caller is realistically going to want the same logic. Reach for it when the ergonomic win of calling a function instead of writing a fetch is worth the coupling to Next.js it introduces. Reach for it when you want automatic revalidation of the current page's data after a mutation, because Next.js's cache invalidation primitives are designed to be triggered from within an action. Reach for it when the operation is small, session-scoped, and unlikely to grow into a public API.

A good heuristic: if the answer to "who else is going to call this in the next twelve months" is "just this form," it's a Server Action. If the answer is "the mobile app, a webhook, or a cron," it's an Edge Function.

Trade-offs at a glance

Edge Functions cost you a little ergonomic overhead per call site and give you back portability, schedulability, and independent deployment. Server Actions cost you portability and give you back a shorter, cleaner call site that plays natively with Next.js caching. Neither one is a strict superset of the other. A mature codebase usually contains both: Server Actions for the interactive form submissions that make up the day-to-day product, Edge Functions for the webhooks, cron jobs, and public API surfaces that live alongside the product.

There's also a middle path worth naming. A Server Action can call an Edge Function. This is a legitimate pattern when the Server Action wants the ergonomic Next.js integration for the caller and the Edge Function contains the actual business logic that also needs to be reachable from a webhook. The Server Action becomes a thin adapter; the Edge Function is the source of truth. This split is more code than either option alone, but it's often the right split for logic that has one interactive caller and one machine caller.

Migrating between the two

Because both options end up talking to the same Postgres database through the same Supabase client, moving logic from one to the other is mostly a mechanical exercise. The hard part isn't the code; it's the caller. Migrating a Server Action to an Edge Function means every call site now needs to make an HTTP request with an Authorization header, and error handling shifts from a thrown exception to a non-2xx response. Migrating an Edge Function to a Server Action means giving up any non-Next.js caller and rewiring the operation into React's mutation lifecycle. Do the migration only when you have a concrete second caller, or a concrete performance problem, that justifies the churn.

Closing pointer

The short version: Supabase Edge Functions are HTTP endpoints at the edge, reachable by anything, deployed independently, and priced by invocation. Next.js Server Actions are RPC-shaped functions inside your React tree, reachable only from your app, deployed with your app, and free of a distinct billing surface. Use Edge Functions for anything a webhook, a mobile app, a cron, or a third party will ever call. Use Server Actions for the interactive mutations that live and die inside a single form submission. When in doubt, write the simpler one first and promote to the more general one only when a second caller shows up. That single rule will keep your codebase honest for a surprisingly long time.