TL;DR
- Anything bundled into your React Native app can be extracted. That includes your
.envAPI keys. - Edge functions give you a tiny server-side layer without running a backend.
- Supabase Edge Functions run on Deno, deploy with one command, and are called from your app with
supabase.functions.invoke(). - Use them for: secret-holding API proxies, webhooks, and logic you don't want users to tamper with.
- Don't use them for: long-running jobs or heavy compute.
The problem nobody warns you about
Here's a pattern I see in a lot of Expo apps:
// ❌ Don't do this
const res = await fetch("https://api.openai.com/v1/chat/completions", {
headers: {
Authorization: `Bearer ${process.env.EXPO_PUBLIC_OPENAI_KEY}`,
},
// ...
});
The EXPO_PUBLIC_ prefix is the giveaway: that value is inlined into your JS bundle. Anyone can download your app, unpack it, and read the key. react-native-dotenv, obfuscation, or "hiding" it in a config file doesn't change this. If the device has it, the user has it.
The fix is to never send the secret to the device at all. Your app calls your function, and your function calls the third-party API.
That's exactly what edge functions are good at.
What an edge function actually is
An edge function is a small piece of server-side code that:
- runs on demand (no server for you to keep alive),
- is deployed to infrastructure close to your users,
- has access to secrets the client never sees.
Supabase's version runs TypeScript on Deno. If you already use Supabase for auth or Postgres, you get user authentication for free. More on that below.
Step 1: Create the function
With the Supabase CLI installed:
supabase functions new ai-chat
That creates supabase/functions/ai-chat/index.ts. Replace its contents:
// supabase/functions/ai-chat/index.ts
import { createClient } from "jsr:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
Deno.serve(async (req) => {
// Needed if you also run your app on web (Expo web / browser)
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
// 1. Identify the caller from their Supabase session
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: req.headers.get("Authorization")! } } },
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// 2. Validate input — never trust the client
const { message } = await req.json();
if (typeof message !== "string" || message.length > 2000) {
return new Response(JSON.stringify({ error: "Invalid message" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// 3. Call the third-party API with a secret the app never sees
const aiRes = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: message }],
}),
});
const data = await aiRes.json();
const reply = data.choices?.[0]?.message?.content ?? "";
return new Response(JSON.stringify({ reply }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
});
Three things are happening, and all three matter:
- Auth check. Only signed-in users can burn your API credits.
- Input validation. The client controls the request body, so you cap it.
-
Secret stays server-side.
OPENAI_API_KEYlives only in the function's environment.
Step 2: Set the secret and deploy
supabase secrets set OPENAI_API_KEY=sk-...
supabase functions deploy ai-chat
SUPABASE_URL and SUPABASE_ANON_KEY are injected automatically. You only set your own secrets.
For local testing:
supabase start
supabase functions serve ai-chat --env-file ./supabase/.env.local
Step 3: Call it from your Expo app
// lib/supabase.ts
import { createClient } from "@supabase/supabase-js";
import AsyncStorage from "@react-native-async-storage/async-storage";
export const supabase = createClient(
process.env.EXPO_PUBLIC_SUPABASE_URL!,
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!, // public by design — safe to ship
{
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
},
);
// hooks/useAiChat.ts
import { useState } from "react";
import { supabase } from "../lib/supabase";
export function useAiChat() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function send(message: string): Promise<string | null> {
setLoading(true);
setError(null);
const { data, error } = await supabase.functions.invoke("ai-chat", {
body: { message },
});
setLoading(false);
if (error) {
setError(error.message);
return null;
}
return data.reply as string;
}
return { send, loading, error };
}
functions.invoke() attaches the logged-in user's JWT automatically, which is how auth.getUser() in the function knows who's calling. You don't pass tokens around manually.
Note the difference: the anon key is meant to be public. Security comes from Row Level Security and from checks like the one in your function, not from hiding that key.
Other things worth moving to an edge function
Once you have one, you'll find more uses:
| Use case | Why server-side |
|---|---|
| Payment webhooks (Stripe, RevenueCat) | Need to verify signatures with a secret |
| Push notification sending | Your Expo push / FCM credentials stay private |
| Premium-feature checks | Client-side checks are trivially bypassed |
| Calling paid APIs (AI, maps, SMS) | Protect keys and rate-limit per user |
| Sending transactional email | Email provider keys stay private |
When NOT to use edge functions
Edge functions are not a full backend. Avoid them for:
- Long-running work. There are wall-clock and CPU time limits per request. Video processing or big batch jobs belong in a queue/worker.
- Heavy dependencies. Some npm packages that rely on Node-specific APIs won't run cleanly on Deno.
- Chatty DB logic. If a function makes dozens of round trips to Postgres, a database function (RPC) is usually faster.
Also expect occasional cold starts. They're usually short, but show a loading state in your UI instead of assuming instant responses.
Faster way to get the app side done
If you'd rather not hand-wire the Expo screens, auth flow, and Supabase client before you even get to the interesting server-side part, I've been using RapidNative to generate a working Expo app from a prompt, then exporting the code and adding edge functions like the one above. It's a quick way to get to the part of the stack that actually needs thought.
Wrap-up
The rule is simple: if it's a secret or a business rule, it doesn't belong in the bundle. Edge functions are the lowest-effort way to enforce that in a React Native app without spinning up a backend.
What are you running in edge functions right now? Drop a comment, especially if you've hit a limit I didn't mention.
Top comments (0)