Many developers reach a point where they need to add a newsletter to their website or application. While there are many solutions out there, finding one that's both simple to implement and powerful enough for real-world use can be challenging. In this article, I'll show you how to build a newsletter system using Next.js and Buttondown.
Below you'll find the complete code for this project. Each section is explained in detail, but you can also explore the full implementation directly in the code examples. Feel free to use this as a reference while building your own newsletter system.
View Live Demo›
Note: You can also find the complete code on GitHub↗
#Why Buttondown?
Before diving into the implementation, let's address why we're choosing Buttondown. Unlike complex enterprise solutions, Buttondown provides:
- A straightforward API that's easy to work with
- Custom fields for personalizing your newsletters
- Built-in analytics and subscriber tracking
- Double opt-in verification for better list quality
- Reasonable pricing for smaller projects
Most importantly, it lets you focus on your content rather than managing complex infrastructure.
#Why Server Actions?
After trying different approaches for handling form submissions in Next.js, Server Actions stand out for several reasons. Unlike traditional API routes or client-side handlers, Server Actions provide:
- Built-in progressive enhancement for forms that work without JavaScript
- Direct database/API access without exposing credentials to the client
- Type safety from your form all the way to your server code
- Automatic handling of loading and error states
- Zero setup for CSRF protection and security
Most importantly, they eliminate the boilerplate of creating API routes and managing client-side state, letting you focus on your actual business logic.
#Project Structure
app/
├── components/
│ └── newsletter-form.tsx // The form component with validation
├── actions/
│ └── newsletter.ts // Server actions for API handling
├── subscribe-pending/
│ └── page.tsx // Success/verification page
└── confirmed/
└── page.tsx // Final confirmation page
#Components
The newsletter-form.tsx
is our main component handling the subscription form. I prefer keeping it separate because it promotes better code organization and reusability.
By isolating the form logic, validation, and styling in one component, we can easily drop it into any page without duplicating code. This separation also makes it simpler to maintain and update the form's functionality over time - when we need to add new features or fix bugs, we know exactly where to look.
Most importantly, keeping all form-related code together makes it easier for other developers to understand how the newsletter subscription flow works. They can see the entire implementation, from client-side validation to server interaction, in one place.
#Server Actions
The newsletter.ts
file in the actions directory contains our server-side logic. This is where we handle all interactions with Buttondown's API, validate email addresses, process form submissions, and track subscriber metadata. It's the core of our newsletter system.
I always keep API interactions in server actions because they provide better security and performance. Your API keys and sensitive data stay safely on the server, never exposed to the client. Plus, by running these operations server-side, we reduce client-side JavaScript and improve initial page load times.
This approach also makes error handling more robust. When something goes wrong - whether it's an invalid email or an API failure - we can handle it gracefully in one place, providing clear feedback to users without complex client-side state management.
#Pages
We have two important pages:
subscribe-pending
: Shows after initial signupconfirmed
: Displays after email verification
This two-page approach provides a better user experience by clearly communicating the subscription status. Users know exactly what's happening at each step.
I've found this structure works well for most newsletter implementations. It's simple enough to get started but flexible enough to add features as your needs grow.
#The How of Server Actions in Next.js
Let's build a newsletter subscription system with Server Actions. We'll start minimal and understand each piece.
Server Actions in Next.js let you write server-side code directly in your components. Think of them as special functions that only run on your server, keeping sensitive data like API keys safe.
Here's a basic Server Action:
'use server'
interface ActionResponse {
success: boolean
message: string
status?: 'pending_verification' | 'subscribed' | 'error'
redirect?: string
}
export async function subscribeToNewsletter(
_prevState: ActionResponse,
formData: FormData
): Promise<ActionResponse> {
const email = formData.get('email')?.toString().trim()
if (!email) {
return {
success: false,
message: 'Email is required',
status: 'error'
}
}
const apiKey = process.env.BUTTONDOWN_API_KEY
if (!apiKey) {
return {
success: false,
message: 'Newsletter service is not configured',
status: 'error'
}
}
try {
const response = await fetch(
'https://api.buttondown.email/v1/subscribers',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Token ${apiKey}`
},
body: JSON.stringify({
email_address: email,
notes: 'Subscribed through website',
tags: ['website-signup']
})
}
)
if (!response.ok) {
const error = await response.json()
console.error('Buttondown API error:', error)
if (error.detail?.includes('already subscribed')) {
return {
success: false,
message: 'This email is already subscribed',
status: 'error'
}
}
return {
success: false,
message: 'Failed to subscribe. Please try again later.',
status: 'error'
}
}
return {
success: true,
message: 'Please check your email',
status: 'pending_verification',
redirect: '/subscribe-pending'
}
} catch (error) {
console.error('Newsletter subscription error:', error)
return {
success: false,
message: 'Something went wrong. Please try again later.',
status: 'error'
}
}
}
#Why This Approach Works
I love Server Actions for forms because they:
- Keep API keys secure on the server
- Handle form data automatically
- Support progressive enhancement
- Provide type safety with TypeScript
#Building the Newsletter Form Component
Let's create a form that works with our Server Action. Here's a minimal example to get started:
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useFormState, useFormStatus } from 'react-dom';
import { subscribeToNewsletter } from '../actions/newsletter';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-blue-300"
>
{pending ? 'Subscribing...' : 'Subscribe'}
</button>
);
}
export function NewsletterForm() {
const router = useRouter();
const [state, formAction] = useFormState(subscribeToNewsletter, {
success: false,
message: '',
status: undefined,
redirect: undefined,
});
useEffect(() => {
if (state?.redirect) {
router.push(state.redirect);
}
}, [state?.redirect, router]);
return (
<form action={formAction} className="space-y-4">
<input
name="email"
type="email"
placeholder="you@example.com"
required
className="px-4 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 block w-full"
/>
<SubmitButton />
{state?.message && (
<p className={state.success ? 'text-green-500' : 'text-red-500'}>
{state.message}
</p>
)}
</form>
);
}
#Key Concepts
When building form components with Server Actions, there are several React patterns working together to create a seamless user experience. The 'use client'
directive marks our component for client-side rendering, enabling interactive features like real-time validation and loading states.
Form state management becomes simple with useActionState
. This hook connects our form directly to our Server Action, handling both the form submission and any response data. Here's how we set it up:
const [state, formAction] = useActionState(
subscribeToNewsletter,
{
message: '',
success: false
}
)
Loading states are crucial for user feedback. The useFormStatus
hook makes this straightforward:
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>...</button>
}
Once these pieces are in place, our form handles everything automatically - from submission and loading states to success messages and redirects. This declarative approach means less code to maintain and a more reliable user experience.
#Using the Newsletter Form
Now that we have our form component ready, let's create a simple page to display it. Here's a minimal example:
'use client'
import { NewsletterForm } from 'app/components/newsletter-form'
export default function NewsletterPage() {
return (
<div className="max-w-md mx-auto p-6">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">
Learn, build, and grow
</h1>
<p className="text-lg mb-8">
Join developers learning React and TypeScript.
</p>
</div>
<div className="border rounded-xl p-6">
<NewsletterForm />
</div>
</div>
)
}
#Setting Up Redirect Pages
Before creating our pages, we need to configure Buttondown to handle redirects correctly. This provides a better user experience for the subscription flow.
First, navigate to your Buttondown Settings and find the "Custom redirects" section. Here you'll set two important URLs:
Custom subscription redirect: https://yourdomain.com/subscribe-pending
Custom confirmation redirect: https://yourdomain.com/confirmed
This configuration creates a smooth flow for your subscribers: when someone submits the form, they'll see your pending page while they wait for the confirmation email. After clicking the confirmation link, they'll land on your confirmed page. This clear progression helps users understand exactly where they are in the subscription process.
#Creating the Pages
Let's create our subscription confirmation pages. First, for users who just submitted the form:
// app/subscribe-pending/page.tsx
export default function SubscribePending() {
return (
<div className="text-center p-8">
<h1 className="text-2xl font-bold">Check Your Email</h1>
<p>Please confirm your subscription by clicking the link we just sent.</p>
</div>
)
}
And for users who have completed the confirmation process:
// app/confirmed/page.tsx
export default function Confirmed() {
return (
<div className="text-center p-8">
<h1 className="text-2xl font-bold">You're Subscribed! 🎉</h1>
<p>Thank you for confirming your subscription.</p>
</div>
)
}
These simple pages provide clear feedback at each step of the subscription process.
#Recap
Let's review what we built in this tutorial. At its core, we created a newsletter system using Next.js Server Actions and Buttondown, but we went beyond basic implementation to build something production-ready.
Our Server Action handles form submissions securely:
'use server'
async function subscribeToNewsletter(formData: FormData) {
const email = formData.get('email')
// Subscription logic...
}
By combining this with React's form hooks, we created a seamless user experience with proper loading states and error handling. The beauty of this approach is how it eliminates common boilerplate while maintaining type safety throughout.
Perhaps most importantly, we designed a complete subscription flow that guides users through each step: from initial signup to email verification to final confirmation. This attention to user experience makes a significant difference in subscription rates.
The result is a newsletter system that's not just functional, but production-ready and easy to extend with additional features like analytics tracking or custom email templates.
#What's Next?
Now that your newsletter system is working, consider how you'll grow it. Form validation, analytics tracking, custom templates, and tests are natural next steps. The modular nature of Server Actions means you can enhance these pieces incrementally, focusing on what brings the most value to your subscribers.