Complete step-by-step guide for setting up Expo push notifications with Firebase FCM V1, Supabase profiles table, send-notification Edge Function, and EAS cloud builds. Covers Android end-to-end with all gotchas.
Use this skill when setting up push notifications from scratch, migrating from local builds to EAS builds, or debugging InvalidCredentials / notification delivery failures.
Device (Expo Push Token)
→ saved to Supabase profiles table
→ your server/Edge Function queries profiles
→ calls Expo Push API (https://exp.host/--/api/v2/push/send)
→ Expo servers → Firebase FCM V1
→ Firebase → Device
Two separate concerns:
google-services.json (no server key needed)com.yourname.yourapp (must match app.json android.package)google-services.json⚠️ GOTCHA: Do NOT place it in
android/app/. Theandroid/folder is generated and--cleanwill delete it. Always keep it at the project root.
Place at: <project-root>/google-services.json
Add to .gitignore (don't commit this file):
/google-services.json
*firebase-adminsdk*.json
{
"expo": {
"android": {
"googleServicesFile": "./google-services.json"
}
}
}
app.json doesn't support process.env — create app.config.js at the project root:
const { expo } = require('./app.json');
/** @type {import('expo/config').ExpoConfig} */
module.exports = {
...expo,
android: {
...expo.android,
// Cloud builds use the GOOGLE_SERVICES_JSON file env var from EAS
// Local builds fall back to ./google-services.json in project root
googleServicesFile: process.env.GOOGLE_SERVICES_JSON ?? './google-services.json',
},
};
Run this migration via Supabase MCP or dashboard SQL editor:
-- Profiles table (synced from auth.users via trigger)
CREATE TABLE IF NOT EXISTS public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
full_name TEXT,
avatar_url TEXT,
push_token TEXT,
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Enable RLS
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
-- Users can only read/update their own profile
CREATE POLICY "Users can view own profile"
ON public.profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON public.profiles FOR UPDATE
USING (auth.uid() = id);
-- Service role can do anything (needed by Edge Functions)
CREATE POLICY "Service role full access"
ON public.profiles FOR ALL
USING (auth.role() = 'service_role');
-- Auto-create profile on new user sign-up
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
BEGIN
INSERT INTO public.profiles (id, full_name, avatar_url)
VALUES (
new.id,
new.raw_user_meta_data->>'full_name',
new.raw_user_meta_data->>'avatar_url'
);
RETURN new;
END;
$$;
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
To backfill existing users:
INSERT INTO public.profiles (id, full_name, avatar_url)
SELECT
id,
raw_user_meta_data->>'full_name',
raw_user_meta_data->>'avatar_url'
FROM auth.users
ON CONFLICT (id) DO NOTHING;
npx expo install expo-notifications expo-device
import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import { supabase } from '@/lib/supabase';
// Show notifications when app is in foreground
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
});
export async function registerForPushNotificationsAsync(): Promise<string | null> {
if (!Device.isDevice) {
console.warn('[Notifications] Push tokens not available on simulators');
return null;
}
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.warn('[Notifications] Permission not granted');
return null;
}
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
});
}
const token = await Notifications.getExpoPushTokenAsync({
projectId: 'YOUR_EAS_PROJECT_ID', // from app.json extra.eas.projectId
});
console.log('[Notifications] Expo push token:', token.data);
return token.data;
}
export async function savePushToken(token: string): Promise<void> {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { error } = await supabase
.from('profiles')
.upsert({ id: user.id, push_token: token, updated_at: new Date().toISOString() });
if (error) {
console.error('[Notifications] Failed to save push token:', error.message);
} else {
console.log('[Notifications] Push token saved for user:', user.email);
}
}
export async function clearPushToken(): Promise<void> {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
await supabase
.from('profiles')
.upsert({ id: user.id, push_token: null, updated_at: new Date().toISOString() });
}
import * as Notifications from 'expo-notifications';
import { useEffect, useRef } from 'react';
import { useRouter } from 'expo-router';
import type { Session } from '@supabase/supabase-js';
import { registerForPushNotificationsAsync, savePushToken } from '@/utils/notifications';
export function useNotifications(session: Session | null) {
const router = useRouter();
const notificationListener = useRef<Notifications.EventSubscription | null>(null);
const responseListener = useRef<Notifications.EventSubscription | null>(null);
const tokenRefreshListener = useRef<Notifications.EventSubscription | null>(null);
useEffect(() => {
if (!session) return;
registerForPushNotificationsAsync().then((token) => {
if (token) savePushToken(token);
});
notificationListener.current = Notifications.addNotificationReceivedListener(
(notification) => {
console.log('[Notifications] Received:', notification.request.content.title);
}
);
responseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
const data = response.notification.request.content.data as Record<string, string> | undefined;
if (data?.screen) router.push(data.screen as never);
}
);
tokenRefreshListener.current = Notifications.addPushTokenListener((tokenData) => {
console.log('[Notifications] Token refreshed:', tokenData.data);
savePushToken(tokenData.data);
});
return () => {
notificationListener.current?.remove();
responseListener.current?.remove();
tokenRefreshListener.current?.remove();
};
// Key on user ID only — avoids re-running on session object reference changes
}, [session?.user.id]);
}
import { useNotifications } from '@/hooks/use-notifications';
// Inside the layout component, after session is declared:
useNotifications(session);
import { clearPushToken } from '@/utils/notifications';
// In the sign-out handler, before supabase.auth.signOut():
await clearPushToken();
await supabase.auth.signOut();
⚠️ GOTCHA: FCM Legacy API is deprecated. You MUST use FCM V1. The V1 API requires a service account JSON key, not a server key string.
yourapp-firebase-adminsdk-xxxxx.json).gitignore: *firebase-adminsdk*.jsoneas credentials --platform android
Navigate:
development⚠️ GOTCHA: Do NOT select "Push Notifications (Legacy)" — that takes a plain API key string. For FCM V1 you need the Google Service Account submenu.
Verify it's uploaded correctly — the credentials page should show:
Push Notifications (FCM V1): Google Service Account Key For FCM V1
Project ID your-project-id
Client Email [email protected]
After uploading, delete the local JSON file:
Remove-Item "yourapp-firebase-adminsdk-xxxxx.json"
Deploy via Supabase MCP or CLI. The function:
profiles table for push tokensuserId), multiple users (userIds), or all users (omit both)SUPABASE_SERVICE_ROLE_KEY (auto-injected by Supabase, no setup needed)import 'jsr:@supabase/functions-js/edge-runtime.d.ts';
import { createClient } from 'jsr:@supabase/supabase-js@2';
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
Deno.serve(async (req: Request) => {
if (req.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), { status: 405 });
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
const { userId, userIds, title, body, data } = await req.json();
let query = supabase.from('profiles').select('id, push_token').not('push_token', 'is', null);
if (userId) query = query.eq('id', userId);
else if (userIds?.length) query = query.in('id', userIds);
const { data: profiles, error } = await query;
if (error) return new Response(JSON.stringify({ error: 'DB error' }), { status: 500 });
if (!profiles?.length) return new Response(JSON.stringify({ sent: 0 }), { status: 200 });
const messages = profiles.map((p) => ({
to: p.push_token,
title, body, sound: 'default',
...(data ? { data } : {}),
}));
// Send in chunks of 100
const chunks: typeof messages[] = [];
for (let i = 0; i < messages.length; i += 100) chunks.push(messages.slice(i, i + 100));
const results = await Promise.all(
chunks.map((chunk) =>
fetch(EXPO_PUSH_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(chunk),
}).then((r) => r.json()),
),
);
return new Response(JSON.stringify({ sent: messages.length, results }), { status: 200 });
});
Call it:
# One user
curl -X POST https://<ref>.supabase.co/functions/v1/send-notification \
-H "Authorization: Bearer <ANON_KEY>" \
-H "Content-Type: application/json" \
-d '{"userId":"<uuid>","title":"Hello","body":"Test","data":{"screen":"/home"}}'
# All users (broadcast)
# omit userId/userIds:
-d '{"title":"Update","body":"New version available!"}'
npx expo install expo-dev-client
{
"cli": {
"version": ">= 16.28.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"autoIncrement": true,
"android": { "buildType": "apk" }
},
"preview": {
"distribution": "internal",
"autoIncrement": true,
"android": { "buildType": "apk" }
},
"production": {
"autoIncrement": true,
"android": { "buildType": "app-bundle" }
}
},
"submit": {
"production": {}
}
}
buildType: "apk"for dev/preview — directly installable, no Play Store needed.buildType: "app-bundle"for production — required for Play Store.
# All EXPO_PUBLIC_ vars must be "plaintext" visibility (not "secret")
eas env:create --name EXPO_PUBLIC_SUPABASE_URL --value "https://..." \
--visibility plaintext \
--environment development --environment preview --environment production \
--non-interactive
# Repeat for: EXPO_PUBLIC_SUPABASE_ANON_KEY, EXPO_PUBLIC_ANDROID_CLIENT_ID,
# EXPO_PUBLIC_IOS_CLIENT_ID, EXPO_PUBLIC_WEB_CLIENT_ID
⚠️ GOTCHA:
eas secret:createis deprecated. Useeas env:createinstead. ⚠️ GOTCHA:EXPO_PUBLIC_vars should be--visibility plaintext, notsecret. EAS shows an "Attention" warning if you mark them Secret because they're baked into the compiled app bundle anyway.
eas env:create --name GOOGLE_SERVICES_JSON --type file --value "google-services.json" \
--visibility sensitive \
--environment development --environment preview --environment production \
--non-interactive
⚠️ GOTCHA:
--type filetakes--valueas a file path, not file contents. EAS reads and uploads the file.
{
"scripts": {
"build:dev:android": "eas build -p android --profile development",
"build:dev:ios": "eas build -p ios --profile development",
"build:preview:android": "eas build -p android --profile preview",
"build:prod:android": "eas build -p android --profile production",
"build:prod:ios": "eas build -p ios --profile production"
}
}
npm run build:dev:android
When prompted "Generate a new Android Keystore?" → Y.
EAS will:
.jks file)Download and install on your physical Android device.
⚠️ GOTCHA: The EAS-generated keystore has a DIFFERENT SHA-1 than your local
android/app/debug.keystore. Google Sign-In uses SHA-1 to verify the APK — if the EAS SHA-1 is not registered, you getDEVELOPER_ERROR.
Get the EAS SHA-1 from: expo.dev → your project → Credentials → Android → your package name → Keystore → SHA-1 Fingerprint
Then in Google Cloud Console:
⚠️ GOTCHA: Android OAuth clients support only one SHA-1 per client. You cannot add a second SHA-1 to the same client. You must create a second client.
com.yourname.yourapp)Android EAS BuildYou'll end up with two Android clients:
| Client Name | SHA-1 Source | Used By |
|---|---|---|
Android Debug - Local | android/app/debug.keystore | npx expo run:android |
Android EAS Build | EAS managed keystore | npm run build:dev:android |
Both work with the same Web Client ID configured in Supabase. No code changes needed.
# Day-to-day: just start Metro — no rebuild needed
npm start
# Only rebuild when native code changes (new plugins, app.json changes):
npm run build:dev:android
The EAS APK stays installed on your device. It hot-reloads from Metro just like expo run:android.
# Get the user's push token from Supabase profiles table first, then:
Invoke-RestMethod `
-Uri "https://<ref>.supabase.co/functions/v1/send-notification" `
-Method POST `
-Headers @{
"Authorization" = "Bearer <ANON_KEY>"
"Content-Type" = "application/json"
} `
-Body '{"userId":"<uuid>","title":"Test","body":"Hello from Supabase Edge Function!"}' |
ConvertTo-Json -Depth 5
Expected response: {"sent":1,"results":[{"data":[{"status":"ok","id":"..."}]}]}
InvalidCredentials when sending push notificationFCM V1 credentials not uploaded to EAS, or the wrong credentials type was selected.
eas credentials --platform android and select FCM V1 → Google Service AccountInvoke-RestMethod -Uri "https://exp.host/--/api/v2/push/send" -Method Post `
-Headers @{"Content-Type"="application/json"} `
-Body '{"to":"ExponentPushToken[...]","title":"Test","body":"Test"}' | ConvertTo-Json -Depth 5
If this returns "status":"error" with InvalidCredentials, the FCM V1 key is wrong or expired.setNotificationHandler must be called at module level in utils/notifications.ts (outside any component or hook). It must return shouldShowAlert: true and shouldShowBanner: true.
The useNotifications hook's useEffect runs whenever the session object reference changes, even if it's the same user. This happens because Supabase refreshes the session internally.
Fix: key the effect on the user ID string, not the session object:
// Wrong — session object reference changes on refresh:
}, [session]);
// Correct — stable string, only changes on actual login/logout:
}, [session?.user.id]);
DEVELOPER_ERROR on EAS build (Google Sign-In fails)EAS uses a different keystore than local builds. The EAS SHA-1 is not registered in Google Cloud Console. See Step 20.
google-services.json warning during local devFile specified via "android.googleServicesFile" field in your app config is not checked in to your repository.
This warning appears when running npx expo run:android because the file is .gitignored. It's safe — the file exists locally at the project root. The warning is from Expo CLI, not a build failure.
Make sure app.config.js exists at the project root (not just app.json), the GOOGLE_SERVICES_JSON EAS env var was created with --type file, and the env var is set for the build profile you're using.
eas env:create vs eas secret:createeas secret:create is deprecated. Always use eas env:create. Visibility guide:
| Var type | Visibility flag |
|---|---|
EXPO_PUBLIC_* | --visibility plaintext |
GOOGLE_SERVICES_JSON (file) | --visibility sensitive |
| Server-only secrets (e.g. service role key) | --visibility secret |
| Item | Value |
|---|---|
| EAS Project ID | b965fcc1-ecaf-4a6b-920d-579d54702df7 |
| Firebase Project | expo-boilerplate-salehahmed |
| Package name | com.salehahmed.expoboilerplate |
| Local debug SHA-1 | 5E:8F:16:06:2E:A3:CD:2C:4A:0D:54:78:76:BA:A6:F3:8C:AB:F6:25 |
| EAS keystore SHA-1 | 35:C3:F4:8F:BC:10:E5:FE:74:08:D6:37:8D:26:A6:18:36:D8:66:EB |
| Supabase project ref | twhxryigszvzvkizngul |
| Edge Function URL | https://twhxryigszvzvkizngul.supabase.co/functions/v1/send-notification |