BLOGS

Send Notification Emails in Next.js + Supabase (2026)

April 10, 2026

Send transactional and notification emails from Next.js API routes and Server Actions using Supabase for auth, data, and real-time triggers with the Pingram SDK.

Send Notification Emails in Next.js + Supabase (2026)

Motivation

You’re building a Next.js app with Supabase auth and database. Users sign up, perform actions, trigger workflows and at each step you want to implement an email. Supabase’s built-in email is capped at 2 emails/hour and only works for auth-related emails. Now is the time to integrate Pingram into your Next.js backend for additional email capabilities.

Next.js gives you two server-side primitives: Route Handlers (API routes in the App Router) and Server Actions. Both run on the server, both can hold secrets, and both integrate naturally with Supabase’s client library. The question is which pattern fits which use case.

This guide covers four patterns for sending emails from a Next.js + Supabase stack:

PatternBest for
Route HandlerWebhooks, third-party integrations, cron triggers
Server ActionForm submissions, UI-triggered emails
DB webhook (auth.users)Welcome emails when a new auth user row is inserted
DB webhook (e.g. orders)Automatic emails when a row changes

All examples use the Pingram SDK—a JavaScript-friendly provider with a native Node client that fits Next.js on Vercel, dashboard setup, and optional SMTP if you want Supabase Auth to send through the same system; the free tier includes 3,000 emails per month.

Prerequisites

Install dependencies

npm install pingram @supabase/supabase-js @supabase/ssr

Environment variables

Add these to .env.local:

PINGRAM_API_KEY=pingram_sk_...
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
# Prefer the publishable key from your project's API Keys / Connect dialog (sb_publishable_...):
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-key
# Legacy anon key still works during Supabase's key transition—use one or the other:
# NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

Supabase is replacing legacy anon keys with publishable keys. New projects should follow whatever the dashboard Connect flow or API keys docs show; the Route Handler below reads NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY first, then falls back to NEXT_PUBLIC_SUPABASE_ANON_KEY.

Keep PINGRAM_API_KEY and SUPABASE_SERVICE_ROLE_KEY out of client bundles—use them only in server code (Route Handlers, Server Actions, webhooks).

1. Route Handler — Send Email from an API Route

Route Handlers are the App Router’s replacement for pages/api/ routes. They export named functions (GET, POST, etc.) and run exclusively on the server.

Use this pattern when external services need to call your endpoint (webhooks, cron jobs) or when your frontend needs a traditional API call.

Example: invite email

This handler uses @supabase/ssr so the session is read (and refreshed) from cookies correctly, then sends with Pingram.

Create app/api/send-invite/route.ts:

import { NextResponse } from 'next/server';
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import { Pingram } from 'pingram';

export async function POST(request: Request) {
  const pingram = new Pingram({ apiKey: process.env.PINGRAM_API_KEY! });
  const cookieStore = await cookies();
  const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
  const supabaseKey =
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ??
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
  const supabase = createServerClient(supabaseUrl, supabaseKey, {
    cookies: {
      getAll() {
        return cookieStore.getAll();
      },
      setAll(cookiesToSet) {
        try {
          cookiesToSet.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options)
          );
        } catch {
          // ignore if called from a context where cookies can't be set
        }
      }
    }
  });

  const user = (await supabase.auth.getUser()).data.user;

  const { email, teamName } = await request.json();
  const acceptUrl = `https://yourapp.com/accept-invite?email=${encodeURIComponent(email)}`;

  await pingram.send({
    type: 'team_invite',
    to: { id: email, email },
    email: {
      subject: `Invitation to ${teamName} (${user!.email})`,
      html: `<p>Accept invitation: ${acceptUrl}</p>`
    }
  });

  return NextResponse.json({ sent: true });
}

CDN / caching: In production, @supabase/ssr may call setAll with a second argument—headers (e.g. cache-control) that should be applied to the outgoing HTTP response so intermediaries don’t cache personalized responses and accidentally serve one user’s session to another. The snippet above only handles the cookie list to stay short; if you deploy behind a CDN, cache, or anything that stores responses, wire up that second argument per the SSR with Next.js and advanced SSR / caching docs.

Call it from your frontend while the user is signed in so the Supabase session cookie is available to the route:

await fetch('/api/send-invite', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: 'colleague@example.com',
    teamName: 'Acme Corp'
  })
});

From React, use the same request inside an event handler or effect on a client component (same-origin requests send cookies by default):

'use client';

export function SendInviteButton() {
  async function onClick() {
    const res = await fetch('/api/send-invite', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        email: 'colleague@example.com',
        teamName: 'Acme Corp'
      })
    });
    const data = await res.json();
    // handle data / errors
  }

  return <button onClick={onClick}>Send invite</button>;
}

2. Server Action — Send Email from a Form

Server Actions run on the server but are called directly from React components — no API route needed. They’re ideal for form submissions where you want to send an email as a side effect.

Contact form example

Create the Server Action:

// app/actions/contact.ts
'use server';

import { Pingram } from 'pingram';

export async function submitContactForm(formData: FormData) {
  const pingram = new Pingram({ apiKey: process.env.PINGRAM_API_KEY! });
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  const message = formData.get('message') as string;

  await pingram.send({
    type: 'contact_form',
    to: { id: 'support', email: 'support@yourapp.com' },
    email: {
      subject: `Contact form: ${name}`,
      html: `<p>${name} (${email}): ${message}</p>`
    }
  });

  await pingram.send({
    type: 'contact_confirmation',
    to: { id: email, email },
    email: {
      subject: 'We received your message',
      html: `<p>Thanks ${name}, we received your message.</p>`
    }
  });

  return { success: true };
}

Use it from a client component with useActionState so you get pending status and the last result:

// app/components/ContactForm.tsx
'use client';

import { useActionState } from 'react';
import { submitContactForm } from '@/app/actions/contact';

export function ContactForm() {
  const [state, formAction, isPending] = useActionState(
    async (
      _prev: Awaited<ReturnType<typeof submitContactForm>> | null,
      formData: FormData
    ) => submitContactForm(formData),
    null
  );

  return (
    <form action={formAction}>
      <input name="name" placeholder="Your name" required />
      <input name="email" type="email" placeholder="Email" required />
      <textarea name="message" placeholder="Message" required />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Sending…' : 'Send'}
      </button>
      {state?.success ? <p>Message sent.</p> : null}
    </form>
  );
}

Render <ContactForm /> from a Server or Client parent page as needed.

3. Database Webhook on auth.users — Welcome Email on Signup

When a user signs up, Supabase inserts a row into auth.users. A Database Webhook on that table can POST to your Next.js route so you send a custom welcome email. This does not replace Supabase’s auth emails (magic links, confirmations); it adds one on top.

Setup the route handler

Create app/api/auth-webhook/route.ts:

import { NextResponse } from 'next/server';
import { Pingram } from 'pingram';

export async function POST(request: Request) {
  const pingram = new Pingram({ apiKey: process.env.PINGRAM_API_KEY! });
  const payload = await request.json();

  if (
    payload.type === 'INSERT' &&
    payload.schema === 'auth' &&
    payload.table === 'users'
  ) {
    const user = payload.record;
    const dashboardUrl = 'https://yourapp.com/dashboard';

    await pingram.send({
      type: 'welcome',
      to: {
        id: user.id,
        email: user.email
      },
      email: {
        subject: 'Welcome to our platform!',
        html: `<p>Welcome! Open your dashboard: ${dashboardUrl}</p>`
      }
    });
  }

  return NextResponse.json({ received: true });
}

Wire the webhook in Supabase

  1. Open Database Webhooks (from Database or Integrations in the dashboard, depending on your project UI)
  2. Click Create a new webhook
  3. Select the auth.users table and the INSERT event
  4. Set the URL to https://yourapp.com/api/auth-webhook

Every new signup now triggers your welcome email automatically.

4. Database Webhook on Your Tables — Emails on Data Changes

The most powerful pattern: emails fire automatically when your database changes. No frontend code, no manual triggers.

Example: order confirmation

Create app/api/order-webhook/route.ts:

import { NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
import { Pingram } from 'pingram';

export async function POST(request: Request) {
  const pingram = new Pingram({ apiKey: process.env.PINGRAM_API_KEY! });
  const supabaseAdmin = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );
  const payload = await request.json();
  const order = payload.record;

  const { data: user } = await supabaseAdmin
    .from('profiles')
    .select('email, first_name')
    .eq('id', order.user_id)
    .single();

  const orderUrl = `https://yourapp.com/orders/${order.id}`;

  await pingram.send({
    type: 'order_confirmation',
    to: { id: order.user_id, email: user!.email },
    email: {
      subject: `Order #${order.id} confirmed`,
      html: `<p>Order confirmed: ${orderUrl}</p>`
    }
  });

  return NextResponse.json({ sent: true });
}

In the Supabase dashboard, open Database Webhooks, create a hook on the orders table for INSERT, and set the URL to https://yourapp.com/api/order-webhook. You can add another hook on UPDATE later (e.g. shipped notifications) using the same payload shape with record and old_record.

TLDR

Resources