Push Notifications for Self-Hosted Supabase: A Complete FCM Guide

Learn how to implement push notifications in self-hosted Supabase using Edge Functions, database triggers, and Firebase Cloud Messaging.

Cover Image for Push Notifications for Self-Hosted Supabase: A Complete FCM Guide

Push notifications are essential for mobile apps—they keep users engaged even when they're not actively using your application. But here's the thing: Supabase doesn't have native push notification support. If you're running a self-hosted Supabase instance, you'll need to integrate with external services like Firebase Cloud Messaging (FCM) to deliver notifications.

This guide walks you through building a complete push notification system using self-hosted Supabase Edge Functions, database triggers, and FCM—from schema design to production deployment.

Why Supabase Doesn't Include Push Notifications

It's worth understanding why this isn't built in. Push notifications require platform-specific infrastructure:

  • Android relies on FCM in the background for all system notifications
  • iOS uses Apple Push Notification Service (APNs)
  • Web uses the Push API with browser-specific endpoints

Any notification system Supabase could build would need to wrap these services anyway. Rather than adding another abstraction layer, Supabase gives you the building blocks—Edge Functions, webhooks, and database triggers—to integrate directly with notification providers.

The good news: this approach gives you complete control over your notification logic and no vendor lock-in beyond the underlying push services.

Architecture Overview

Here's the system we're building:

User action → Database trigger → Webhook → Edge Function → FCM → Device
  1. Something happens in your app (new message, task assignment, etc.)
  2. Database trigger fires and inserts a row into a notifications table
  3. Webhook detects the insert and calls your Edge Function
  4. Edge Function retrieves the user's FCM token and sends the push notification
  5. FCM delivers the notification to the user's device

This pattern is reliable, scalable, and works identically on self-hosted and cloud Supabase.

Setting Up the Database Schema

First, create the tables that store device tokens and pending notifications.

Device Tokens Table

Users might have multiple devices. Store each device's FCM token:

create table public.device_tokens (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  fcm_token text not null,
  device_type text check (device_type in ('ios', 'android', 'web')),
  created_at timestamptz default now(),
  updated_at timestamptz default now(),
  unique(user_id, fcm_token)
);

-- Index for fast lookups
create index idx_device_tokens_user_id on public.device_tokens(user_id);

-- RLS policies
alter table public.device_tokens enable row level security;

create policy "Users can manage their own device tokens"
  on public.device_tokens
  for all
  using (auth.uid() = user_id);

Notifications Table

This table queues notifications for delivery:

create table public.notifications (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  title text not null,
  body text not null,
  data jsonb default '{}'::jsonb,
  status text default 'pending' check (status in ('pending', 'sent', 'failed')),
  retry_count int default 0,
  error_message text,
  created_at timestamptz default now(),
  sent_at timestamptz
);

-- Index for processing pending notifications
create index idx_notifications_pending on public.notifications(status, created_at)
  where status = 'pending';

-- Index for cleanup of old notifications
create index idx_notifications_sent_at on public.notifications(sent_at)
  where sent_at is not null;

-- RLS: users can read their notifications, system can write
alter table public.notifications enable row level security;

create policy "Users can read their notifications"
  on public.notifications
  for select
  using (auth.uid() = user_id);

Configuring Firebase Cloud Messaging

Before writing the Edge Function, set up FCM:

1. Create a Firebase Project

  1. Go to Firebase Console
  2. Create a new project or use an existing one
  3. Navigate to Project Settings → Cloud Messaging
  4. Note your Server Key (legacy API) or set up a Service Account (recommended for FCM v1)

2. Generate Service Account Credentials

For the FCM HTTP v1 API (recommended):

  1. Go to Project Settings → Service Accounts
  2. Click "Generate new private key"
  3. Save the JSON file securely—you'll need this for your Edge Function

The JSON looks like:

{
  "type": "service_account",
  "project_id": "your-project-id",
  "private_key_id": "...",
  "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
  "client_email": "[email protected]",
  ...
}

Building the Edge Function

Create the Edge Function that processes notifications and sends them via FCM.

Function Structure

mkdir -p volumes/functions/push

Create volumes/functions/push/index.ts:

import { serve } from "https://deno.land/[email protected]/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

// FCM v1 API requires OAuth2 access token
async function getAccessToken(serviceAccount: any): Promise<string> {
  const now = Math.floor(Date.now() / 1000);
  const payload = {
    iss: serviceAccount.client_email,
    sub: serviceAccount.client_email,
    aud: "https://oauth2.googleapis.com/token",
    iat: now,
    exp: now + 3600,
    scope: "https://www.googleapis.com/auth/firebase.messaging",
  };

  // Create JWT
  const encoder = new TextEncoder();
  const header = btoa(JSON.stringify({ alg: "RS256", typ: "JWT" }));
  const body = btoa(JSON.stringify(payload));
  const signatureInput = `${header}.${body}`;

  // Import private key and sign
  const privateKey = await crypto.subtle.importKey(
    "pkcs8",
    pemToArrayBuffer(serviceAccount.private_key),
    { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
    false,
    ["sign"]
  );

  const signature = await crypto.subtle.sign(
    "RSASSA-PKCS1-v1_5",
    privateKey,
    encoder.encode(signatureInput)
  );

  const jwt = `${signatureInput}.${btoa(
    String.fromCharCode(...new Uint8Array(signature))
  )}`;

  // Exchange JWT for access token
  const response = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
  });

  const data = await response.json();
  return data.access_token;
}

function pemToArrayBuffer(pem: string): ArrayBuffer {
  const b64 = pem
    .replace("-----BEGIN PRIVATE KEY-----", "")
    .replace("-----END PRIVATE KEY-----", "")
    .replace(/\n/g, "");
  const binary = atob(b64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes.buffer;
}

async function sendPushNotification(
  accessToken: string,
  projectId: string,
  fcmToken: string,
  title: string,
  body: string,
  data: Record<string, string>
): Promise<{ success: boolean; error?: string }> {
  const response = await fetch(
    `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${accessToken}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        message: {
          token: fcmToken,
          notification: { title, body },
          data,
          android: {
            priority: "high",
          },
          apns: {
            payload: {
              aps: {
                sound: "default",
                badge: 1,
              },
            },
          },
        },
      }),
    }
  );

  if (!response.ok) {
    const error = await response.text();
    return { success: false, error };
  }

  return { success: true };
}

serve(async (req) => {
  try {
    // Parse webhook payload
    const { record } = await req.json();
    const notification = record;

    // Initialize Supabase client
    const supabase = createClient(
      Deno.env.get("SUPABASE_URL")!,
      Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
    );

    // Get user's device tokens
    const { data: tokens, error: tokenError } = await supabase
      .from("device_tokens")
      .select("fcm_token, device_type")
      .eq("user_id", notification.user_id);

    if (tokenError || !tokens?.length) {
      // No tokens, mark as failed
      await supabase
        .from("notifications")
        .update({
          status: "failed",
          error_message: "No device tokens found",
        })
        .eq("id", notification.id);

      return new Response(JSON.stringify({ error: "No tokens" }), {
        status: 200,
      });
    }

    // Get FCM credentials
    const serviceAccount = JSON.parse(
      Deno.env.get("FIREBASE_SERVICE_ACCOUNT")!
    );
    const accessToken = await getAccessToken(serviceAccount);

    // Send to all devices
    const results = await Promise.all(
      tokens.map((token) =>
        sendPushNotification(
          accessToken,
          serviceAccount.project_id,
          token.fcm_token,
          notification.title,
          notification.body,
          notification.data || {}
        )
      )
    );

    const allSucceeded = results.every((r) => r.success);
    const errors = results
      .filter((r) => !r.success)
      .map((r) => r.error)
      .join("; ");

    // Update notification status
    await supabase
      .from("notifications")
      .update({
        status: allSucceeded ? "sent" : "failed",
        error_message: errors || null,
        sent_at: allSucceeded ? new Date().toISOString() : null,
      })
      .eq("id", notification.id);

    return new Response(JSON.stringify({ success: allSucceeded }), {
      status: 200,
    });
  } catch (error) {
    console.error("Push notification error:", error);
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500,
    });
  }
});

Deploy the Edge Function

If you're following the Edge Functions setup guide:

# Restart to pick up the new function
docker compose restart functions

Set the Firebase service account as an environment variable:

# In your .env file or via docker secrets
FIREBASE_SERVICE_ACCOUNT='{"type":"service_account","project_id":"..."}'

Configuring the Database Webhook

Connect your notifications table to the Edge Function using a webhook.

Via Supabase CLI

supabase functions deploy push

Then create the webhook trigger:

-- Create the http extension if not exists
create extension if not exists http;

-- Create trigger function
create or replace function notify_push()
returns trigger as $$
begin
  perform net.http_post(
    url := 'http://functions:9000/functions/v1/push',
    headers := jsonb_build_object(
      'Authorization', 'Bearer ' || current_setting('supabase.service_role_key'),
      'Content-Type', 'application/json'
    ),
    body := jsonb_build_object(
      'type', 'INSERT',
      'table', 'notifications',
      'record', row_to_json(new)
    )
  );
  return new;
end;
$$ language plpgsql security definer;

-- Create trigger
create trigger on_notification_insert
  after insert on public.notifications
  for each row
  execute function notify_push();

Via Supabase Dashboard

If you have access to the dashboard:

  1. Navigate to Database → Webhooks
  2. Create new webhook
  3. Table: notifications, Event: INSERT
  4. HTTP: Supabase Edge Functions → push

Registering Device Tokens

On the client side, register FCM tokens when users log in:

React Native with Expo

import * as Notifications from "expo-notifications";
import { supabase } from "./supabase";

export async function registerPushToken() {
  const { status } = await Notifications.requestPermissionsAsync();
  if (status !== "granted") return;

  const token = await Notifications.getExpoPushTokenAsync();

  // Get the FCM token from Expo
  const fcmToken = token.data;

  // Store in Supabase
  const { data: user } = await supabase.auth.getUser();
  if (!user) return;

  await supabase.from("device_tokens").upsert(
    {
      user_id: user.user.id,
      fcm_token: fcmToken,
      device_type: Platform.OS,
    },
    { onConflict: "user_id, fcm_token" }
  );
}

Flutter

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

Future<void> registerPushToken() async {
  final messaging = FirebaseMessaging.instance;

  await messaging.requestPermission();
  final fcmToken = await messaging.getToken();

  if (fcmToken == null) return;

  final user = Supabase.instance.client.auth.currentUser;
  if (user == null) return;

  await Supabase.instance.client.from('device_tokens').upsert({
    'user_id': user.id,
    'fcm_token': fcmToken,
    'device_type': Platform.isIOS ? 'ios' : 'android',
  }, onConflict: 'user_id, fcm_token');
}

Sending Notifications from Your App

Now you can trigger notifications by inserting into the notifications table:

-- From a database function or trigger
insert into public.notifications (user_id, title, body, data)
values (
  'user-uuid-here',
  'New Message',
  'You have a new message from Alice',
  '{"type": "message", "message_id": "123"}'::jsonb
);

Example: Notify on New Chat Message

create or replace function notify_new_message()
returns trigger as $$
begin
  -- Notify the recipient, not the sender
  if new.sender_id != new.recipient_id then
    insert into public.notifications (user_id, title, body, data)
    select
      new.recipient_id,
      (select display_name from profiles where id = new.sender_id),
      substring(new.content from 1 for 100),
      jsonb_build_object(
        'type', 'message',
        'chat_id', new.chat_id,
        'message_id', new.id
      );
  end if;

  return new;
end;
$$ language plpgsql security definer;

create trigger on_message_insert
  after insert on public.messages
  for each row
  execute function notify_new_message();

Handling Failed Notifications

FCM tokens expire or become invalid. Handle failures gracefully:

Retry Logic

Add a scheduled job to retry failed notifications:

-- Retry failed notifications (max 3 attempts)
select cron.schedule(
  'retry-failed-notifications',
  '*/5 * * * *',  -- Every 5 minutes
  $$
    update public.notifications
    set status = 'pending',
        retry_count = retry_count + 1
    where status = 'failed'
      and retry_count < 3
      and created_at > now() - interval '1 hour';
  $$
);

Clean Up Invalid Tokens

When FCM returns a "not registered" error, remove the token:

// In your Edge Function, after sendPushNotification
if (error?.includes("not registered") || error?.includes("invalid token")) {
  await supabase
    .from("device_tokens")
    .delete()
    .eq("fcm_token", token.fcm_token);
}

Production Considerations

Rate Limiting

FCM has rate limits. For high-volume applications:

  • Batch notifications using FCM's multicast feature (up to 500 tokens per request)
  • Implement queuing with exponential backoff
  • Consider a dedicated job queue like pgmq

Monitoring

Track notification delivery metrics:

-- Delivery stats view
create view notification_stats as
select
  date_trunc('hour', created_at) as hour,
  count(*) as total,
  count(*) filter (where status = 'sent') as sent,
  count(*) filter (where status = 'failed') as failed,
  avg(extract(epoch from (sent_at - created_at))) as avg_delivery_seconds
from public.notifications
where created_at > now() - interval '7 days'
group by 1
order by 1 desc;

Security

  • Never expose FCM tokens in client-side code
  • Use Row Level Security to protect device_tokens table
  • Rotate Firebase service account keys periodically

Simplifying Notification Management

Managing push notifications alongside other Supabase services adds operational complexity. Supascale provides a unified dashboard for monitoring your self-hosted projects, making it easier to track notification delivery and debug issues.

Check our pricing page to see how Supascale can reduce your operational burden.

Conclusion

Push notifications on self-hosted Supabase require more setup than managed services, but you get complete control over your notification logic and infrastructure. The pattern we've built—database triggers → webhook → Edge Function → FCM—is reliable, scalable, and works with any mobile framework.

Start simple: get basic notifications working first, then add retry logic, batching, and monitoring as your needs grow.


Further Reading