BLOGS
Send Notification Emails in Next.js + Supabase (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.
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:
| Pattern | Best for |
|---|---|
| Route Handler | Webhooks, third-party integrations, cron triggers |
| Server Action | Form 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
- Next.js 15+ with the App Router (
await cookies()as below matches the current Next API; on older 14.x you can callcookies()withoutawaitif your version still returns the store synchronously) - A Supabase project
- A Pingram account — grab your API key (
pingram_sk_...) from the dashboard - Node.js 18+
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
- Open Database Webhooks (from Database or Integrations in the dashboard, depending on your project UI)
- Click Create a new webhook
- Select the
auth.userstable and theINSERTevent - 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
- Route Handlers (
app/api/*/route.ts) for webhooks and API-driven emails - Server Actions (
'use server') for form submissions and UI-triggered emails - Database webhook on
auth.usersfor welcome emails on signup - DB webhooks on your own tables for emails when data changes
- Pingram SDK handles the sending — install, initialize, call
pingram.send()