Build a Serverless Newsletter System with Next.js and Supabase

Learn how to create a modern, scalable email newsletter system using Next.js and Supabase without managing complex infrastructure.

Back

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

  1. Go to Supabase and sign in or create an account
  2. 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
  3. 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:

  1. In the Supabase dashboard, go to the "Table Editor" section

  2. 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: text
      • created_at: timestamptz (default: now())
      • confirmed: boolean (default: false)
      • confirmation_token: uuid (default: uuid_generate_v4())
  3. Click "Save" to create the table

Setting Up Row Level Security (RLS)

For security, let's configure RLS policies:

  1. Go to the "Authentication" section, then "Policies"
  2. Select the subscribers table
  3. Add a policy for INSERT:
    • Name: "Allow public signup"
    • Using expression: true
    • With check expression: true
  4. 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:

  1. Create a new table named admin_users
  2. 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