Table of Contents
Building a Serverless Email Newsletter System with Next.js and Supabase
A complete guide to creating a modern, serverless newsletter subscription system using Next.js and Supabase, with step-by-step instructions for setup, database configuration, and email delivery.
Email newsletters remain one of the most effective ways to connect with your audience. But setting up and maintaining a newsletter system traditionally required complex infrastructure and ongoing maintenance. Thanks to serverless technologies like Next.js and Supabase, you can now build a powerful newsletter system without managing servers, databases, or email delivery services separately.
In this comprehensive guide, I'll walk you through creating a complete serverless newsletter system that's scalable, secure, and easy to maintain.
🌟 Why Go Serverless for Your Newsletter?
Before diving into the technical implementation, let's understand why a serverless approach makes sense for newsletter systems:
- Cost-effective: You only pay for what you use, with no idle server costs
- Automatic scaling: Handles subscriber growth without manual intervention
- Simplified maintenance: No server patches or database administration
- Focus on content: Spend time on your newsletter content, not infrastructure
- Built-in security: Leverage platform security features instead of building your own
The combination of Next.js for the frontend and API routes, plus Supabase for database and authentication, creates a powerful foundation for your newsletter system.
🛠 Setting Up Your Development Environment
Let's start by setting up a new Next.js project and connecting it to Supabase.
Creating a New Next.js Project
npx create-next-app@latest newsletter-app
cd newsletter-app
When prompted, select the following options:
- TypeScript: Yes
- ESLint: Yes
- Tailwind CSS: Yes (optional, but recommended for styling)
- App Router: Yes
- Import alias: Keep default (@/*)
Installing Supabase Client
Install the Supabase JavaScript client to interact with your Supabase backend:
npm install @supabase/supabase-js
Setting Up Environment Variables
Create a .env.local
file in your project root to store your Supabase credentials:
NEXT_PUBLIC_SUPABASE_URL=your-supabase-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
You'll get these values after setting up your Supabase project in the next step.
🗄️ Creating and Configuring Your Supabase Project
Setting Up a New Supabase Project
- Go to Supabase and sign in or create an account
- Click "New Project" and fill in the details:
- Organization: Select or create an organization
- Name: "newsletter-app" (or your preferred name)
- Database Password: Generate a strong password
- Region: Choose the region closest to your target audience
- Click "Create New Project" and wait for the setup to complete
Creating the Subscribers Table
Once your project is ready, we'll create a table to store newsletter subscribers:
-
In the Supabase dashboard, go to the "Table Editor" section
-
Click "New Table" and configure it as follows:
- Name:
subscribers
- Enable Row Level Security (RLS): Yes
- Columns:
id
: uuid (primary key)email
: text (not null, unique)first_name
: textcreated_at
: timestamptz (default: now())confirmed
: boolean (default: false)confirmation_token
: uuid (default: uuid_generate_v4())
- Name:
-
Click "Save" to create the table
Setting Up Row Level Security (RLS)
For security, let's configure RLS policies:
- Go to the "Authentication" section, then "Policies"
- Select the
subscribers
table - Add a policy for INSERT:
- Name: "Allow public signup"
- Using expression:
true
- With check expression:
true
- Add a policy for SELECT, UPDATE, DELETE:
- Name: "Only admins can manage subscribers"
- Using expression:
auth.role() = 'authenticated' AND auth.email() IN (SELECT email FROM admin_users)
Now let's create the admin_users
table to specify who can manage subscribers:
- Create a new table named
admin_users
- Columns:
id
: uuid (primary key)email
: text (not null, unique)created_at
: timestamptz (default: now())
Add your email to this table to grant yourself admin access.
📝 Building the Newsletter Subscription Form
Now, let's create a subscription form component in our Next.js application.
Creating the Form Component
Create a new file at components/SubscriptionForm.tsx
:
'use client';
import { useState } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
export default function SubscriptionForm() {
const [email, setEmail] = useState('');
const [firstName, setFirstName] = useState('');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const supabase = createClientComponentClient();
const handleSubscribe = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setMessage('');
setError('');
try {
// Check if email already exists
const { data: existingUser } = await supabase
.from('subscribers')
.select('email')
.eq('email', email)
.single();
if (existingUser) {
setError('This email is already subscribed!');
setLoading(false);
return;
}
// Insert new subscriber
const { error: insertError } = await supabase
.from('subscribers')
.insert([
{ email, first_name: firstName }
]);
if (insertError) throw insertError;
// Send confirmation email (we'll implement this API route later)
await fetch('/api/send-confirmation', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, firstName }),
});
setMessage('Thanks for subscribing! Please check your email to confirm.');
setEmail('');
setFirstName('');
} catch (err) {
console.error('Error:', err);
setError('Something went wrong. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-4">Subscribe to Our Newsletter</h2>
{message && (
<div className="mb-4 p-2 bg-green-100 text-green-700 rounded">
{message}
</div>
)}
{error && (
<div className="mb-4 p-2 bg-red-100 text-red-700 rounded">
{error}
</div>
)}
<form onSubmit={handleSubscribe}>
<div className="mb-4">
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-1">
First Name
</label>
<input
id="firstName"
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
/>
</div>
<div className="mb-4">
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email Address
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition"
>
{loading ? 'Subscribing...' : 'Subscribe'}
</button>
</form>
</div>
);
}
Adding the Form to Your Homepage
Now, add the subscription form to your homepage by updating app/page.tsx
:
import SubscriptionForm from '@/components/SubscriptionForm';
export default function Home() {
return (
<main className="min-h-screen p-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl font-bold text-center mb-8">
My Awesome Newsletter
</h1>
<p className="text-lg text-center mb-8">
Subscribe to receive weekly updates on the latest trends and insights.
</p>
<SubscriptionForm />
</div>
</main>
);
}
📧 Setting Up Email Confirmation
Let's implement the email confirmation flow to verify subscribers' email addresses.
Creating the Confirmation API Route
Create a new file at app/api/send-confirmation/route.ts
:
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { email, firstName } = await request.json();
const supabase = createRouteHandlerClient({ cookies });
try {
// Get the confirmation token
const { data: subscriber, error } = await supabase
.from('subscribers')
.select('confirmation_token')
.eq('email', email)
.single();
if (error) throw error;
const confirmationToken = subscriber.confirmation_token;
const confirmationUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/confirm?token=${confirmationToken}`;
// Send email using Supabase Edge Functions
const { error: emailError } = await supabase.functions.invoke('send-confirmation-email', {
body: {
email,
firstName,
confirmationUrl
}
});
if (emailError) throw emailError;
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error sending confirmation email:', error);
return NextResponse.json(
{ error: 'Failed to send confirmation email' },
{ status: 500 }
);
}
}
Creating a Supabase Edge Function for Email Delivery
For sending emails, we'll use a Supabase Edge Function. First, install the Supabase CLI:
npm install -g supabase
Initialize Supabase in your project:
supabase init
Create a new Edge Function:
supabase functions new send-confirmation-email
Edit the function in supabase/functions/send-confirmation-email/index.ts
:
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
// This example uses Resend, but you can use any email service
const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY') || '';
interface EmailPayload {
email: string;
firstName: string;
confirmationUrl: string;
}
serve(async (req) => {
const { email, firstName, confirmationUrl } = await req.json() as EmailPayload;
// Validate inputs
if (!email || !confirmationUrl) {
return new Response(
JSON.stringify({ error: 'Email and confirmationUrl are required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
try {
// Send email using Resend
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Newsletter <newsletter@yourdomain.com>',
to: email,
subject: 'Confirm Your Newsletter Subscription',
html: `
<h1>Hello ${firstName || 'there'}!</h1>
<p>Thank you for subscribing to our newsletter.</p>
<p>Please confirm your subscription by clicking the link below:</p>
<p><a href="${confirmationUrl}">Confirm Subscription</a></p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
}),
});
const data = await res.json();
if (res.status >= 400) {
throw new Error(data.message || 'Failed to send email');
}
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
return new Response(
JSON.stringify({ error: error.message }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
});
Deploy the function to Supabase:
supabase functions deploy send-confirmation-email --no-verify-jwt
Remember to set the RESEND_API_KEY
environment variable in the Supabase dashboard.
Creating the Confirmation Page
Create a new file at app/confirm/page.tsx
:
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
export default function ConfirmSubscription() {
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [message, setMessage] = useState('Confirming your subscription...');
useEffect(() => {
const confirmSubscription = async () => {
if (!token) {
setStatus('error');
setMessage('Invalid confirmation link. Missing token.');
return;
}
const supabase = createClientComponentClient();
try {
// Find subscriber with this token
const { data: subscriber, error: findError } = await supabase
.from('subscribers')
.select('id, email, confirmed')
.eq('confirmation_token', token)
.single();
if (findError || !subscriber) {
setStatus('error');
setMessage('Invalid confirmation link. Please try subscribing again.');
return;
}
if (subscriber.confirmed) {
setStatus('success');
setMessage('Your subscription is already confirmed. Thank you!');
return;
}
// Update subscriber to confirmed
const { error: updateError } = await supabase
.from('subscribers')
.update({ confirmed: true })
Written by
Marcus Ruud
At
Tue Nov 21 2023