Building a Modern Newsletter System with Next.js and Buttondown

Creating a newsletter subscription system might seem straightforward at first, but building one that's polished, user-friendly, and robust requires careful consideration of many aspects. In this post, I'll share how I built the newsletter system for this website using Next.js, Buttondown, and various modern web technologies.

Features Overview

The newsletter system includes several key features:

  • 🌐 Internationalization support for multiple languages
  • ✨ Smooth animations and transitions
  • 🛡️ Rate limiting and validation
  • 🎨 Beautiful UI with gradient animations
  • 🔄 Comprehensive error handling
  • 📱 Responsive design
  • 🌙 Dark mode support
  • 🔄 Centralized state management with reducers

Technical Implementation

1. Type-Safe Error Handling with Enums

We use TypeScript enums for type-safe error handling, ensuring consistent error codes across the application:

export enum NewsletterErrorCode {
  InvalidEmail = 'INVALID_EMAIL',
  AlreadySubscribed = 'ALREADY_SUBSCRIBED',
  RateLimited = 'RATE_LIMITED',
  ServerError = 'SERVER_ERROR',
  UnknownError = 'UNKNOWN_ERROR',
}

This approach provides better type safety and autocompletion compared to string literals.

2. State Management with Reducer Pattern

We use a reducer pattern for centralized state management, making the code more maintainable and predictable:

type NewsletterFormState = {
  email: string;
  status: FormStatus;
  message: string;
};

type NewsletterFormAction =
  | { type: 'SET_EMAIL'; payload: string }
  | { type: 'SET_LOADING' }
  | { type: 'SET_SUCCESS' }
  | { type: 'SET_ERROR'; payload: string }
  | { type: 'RESET' };

export function newsletterFormReducer(
  state: NewsletterFormState,
  action: NewsletterFormAction
): NewsletterFormState {
  switch (action.type) {
    case 'SET_EMAIL':
      return { ...state, email: action.payload };
    case 'SET_LOADING':
      return { ...state, status: FormStatus.Loading, message: '' };
    case 'SET_SUCCESS':
      return { ...state, status: FormStatus.Success, email: '', message: '' };
    case 'SET_ERROR':
      return { ...state, status: FormStatus.Error, message: action.payload };
    case 'RESET':
      return { ...state, status: FormStatus.Idle, message: '' };
    default:
      return state;
  }
}

This approach offers several benefits:

  • Centralized state logic in one place
  • Type-safe state transitions
  • Predictable state updates
  • Easier testing with pure functions
  • Better code organization

The form component uses the reducer with React's useReducer hook:

const [state, dispatch] = useReducer(newsletterFormReducer, initialState);

// Example usage in form submission
const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault();
  dispatch({ type: 'SET_LOADING' });
  try {
    // Form submission logic...
    dispatch({ type: 'SET_SUCCESS' });
  } catch {
    dispatch({ type: 'SET_ERROR', payload: errorMessage });
  }
};

Alternative State Management Solutions

We can also implement the state management using popular libraries. Here are three different approaches:

Note: You can find each implementation in the src/components/newsletter directory.

Zustand Implementation

Zustand offers a minimalistic approach with hooks-based state management.

Benefits:

  • Minimal boilerplate
  • Simple hooks-based API
  • Built-in TypeScript support
  • Easy to understand and maintain
View Code
import { create } from 'zustand';

type NewsletterState = {
  email: string;
  status: FormStatus;
  message: string;
  setEmail: (email: string) => void;
  setLoading: () => void;
  setSuccess: () => void;
  setError: (message: string) => void;
  reset: () => void;
};

export const useNewsletterStore = create<NewsletterState>(set => ({
  email: '',
  status: FormStatus.Idle,
  message: '',
  setEmail: email => set({ email }),
  setLoading: () => set({ status: FormStatus.Loading, message: '' }),
  setSuccess: () => set({ status: FormStatus.Success, email: '', message: '' }),
  setError: message => set({ status: FormStatus.Error, message }),
  reset: () => set({ status: FormStatus.Idle, message: '' }),
}));

// Usage Example
function NewsletterForm() {
  const { email, status, message, setEmail, setLoading, setSuccess, setError } =
    useNewsletterStore();

  // ... rest of the component
}

Jotai Implementation

Jotai provides atomic state management with fine-grained updates.

Benefits:

  • Atomic state updates
  • Fine-grained reactivity
  • Great for complex state dependencies
  • Built-in React Suspense support
View Code
import { atom } from 'jotai';

type NewsletterState = {
  email: string;
  status: FormStatus;
  message: string;
};

export const newsletterAtom = atom<NewsletterState>({
  email: '',
  status: FormStatus.Idle,
  message: '',
});

// Derived atoms for individual state pieces
export const emailAtom = atom(
  get => get(newsletterAtom).email,
  (get, set, email: string) => set(newsletterAtom, { ...get(newsletterAtom), email })
);

// Action atoms
export const setLoadingAtom = atom(null, (get, set) =>
  set(newsletterAtom, { ...get(newsletterAtom), status: FormStatus.Loading, message: '' })
);

// Usage Example
function NewsletterForm() {
  const [email, setEmail] = useAtom(emailAtom);
  const [status] = useAtom(statusAtom);
  const [, setLoading] = useAtom(setLoadingAtom);

  // ... rest of the component
}

Valtio Implementation

Valtio offers a proxy-based approach with intuitive state updates.

Benefits:

  • Intuitive mutable syntax
  • Automatic tracking of state updates
  • Works great with vanilla JavaScript
  • Excellent TypeScript support
View Code
import { proxy } from 'valtio';

type NewsletterState = {
  email: string;
  status: FormStatus;
  message: string;
};

export const newsletterState = proxy<NewsletterState>({
  email: '',
  status: FormStatus.Idle,
  message: '',
});

export const newsletterActions = {
  setEmail: (email: string) => {
    newsletterState.email = email;
  },
  setLoading: () => {
    newsletterState.status = FormStatus.Loading;
    newsletterState.message = '';
  },
  // ... other actions
};

// Usage Example
function NewsletterForm() {
  const state = useSnapshot(newsletterState);

  const handleSubmit = async (e: React.FormEvent) => {
    newsletterActions.setLoading();
    // ... rest of the logic
  };
}

Comparing the Approaches

Each state management solution has its strengths:

  1. Reducer Pattern

    • Traditional and well-understood
    • Great for complex state logic
    • Built into React
    • No additional dependencies
  2. Zustand

    • Minimal boilerplate
    • Simple API
    • Great for small to medium applications
    • Easy to learn
  3. Jotai

    • Atomic updates
    • Fine-grained control
    • Great for complex state relationships
    • React Suspense ready
  4. Valtio

    • Intuitive API
    • Proxy-based updates
    • Works outside React
    • Familiar syntax

Choose the approach that best fits your needs:

  • Use the Reducer Pattern for traditional React applications
  • Use Zustand for simple state management with minimal setup
  • Use Jotai when you need atomic state with fine-grained updates
  • Use Valtio when you want intuitive, mutable-style updates

3. Form Status Management

We manage form states using a dedicated enum, making the code more maintainable and type-safe:

export enum FormStatus {
  Idle = 'idle',
  Loading = 'loading',
  Success = 'success',
  Error = 'error',
}

This enum is used throughout the component for consistent state management and animations.

4. Zod Schema with Enum Integration

Our Zod schema integrates with the error enum for type-safe validation:

export const newsletterSchema = z.object({
  email: z.string().email({ message: NewsletterErrorCode.InvalidEmail }),
  locale: z.string().min(2).max(10),
});

5. API Route with Rate Limiting

The API route includes rate limiting to prevent abuse:

const RATE_LIMIT = {
  WINDOW: 3600000, // 1 hour in milliseconds
  MAX_REQUESTS: 10,
} as const;

We track requests per IP address and enforce a limit of 10 attempts per hour.

6. Animated UI Components

The form features smooth animations using Framer Motion, with variants organized for better maintainability:

export const buttonVariants: Variants = {
  [FormStatus.Idle]: {
    scale: 1,
    transition: {
      type: 'spring',
      stiffness: 400,
      damping: 10,
    },
  },
  [FormStatus.Loading]: {
    opacity: 0.75,
  },
  [FormStatus.Success]: {
    opacity: 0.75,
  },
};

The animations provide visual feedback for:

  • Form state transitions (idle → loading → success/error)
  • Error message appearances
  • Button state changes

7. Buttondown Integration

We use Buttondown as our newsletter service, which offers a clean API and useful features like tags and metadata:

const response = await fetch('https://api.buttondown.email/v1/subscribers', {
  method: 'POST',
  headers: {
    Authorization: `Token ${BUTTONDOWN_API_KEY}`,
  },
  body: JSON.stringify({
    email_address: result.data.email,
    type: 'regular',
    metadata: {
      locale: result.data.locale,
    },
  }),
});

Code Organization

The codebase is organized into several key files:

  1. types.ts - Contains shared types and enums
  2. newsletter-form.tsx - Main form component
  3. newsletter-form.reducer.ts - Reducer for form state management
  4. newsletter-form.variants.ts - Animation variants
  5. validations/newsletter.ts - Validation schema and error handling

This separation of concerns makes the code more maintainable and easier to test.

User Experience Considerations

  1. Visual Feedback: The submit button transforms to show loading and success states, with smooth animations for state transitions.
  2. Error Handling: Error messages appear with a gentle animation and are positioned naturally in the flow.
  3. Responsive Design: The form adapts beautifully to different screen sizes.
  4. Dark Mode: The form respects the user's theme preference.

Future Improvements

Some potential enhancements we could add:

  1. Analytics: Track subscription sources and conversion rates
  2. A/B Testing: Test different form layouts and copy
  3. Welcome Emails: Implement automated welcome emails in the subscriber's preferred language
  4. Social Proof: Add a subscriber count or testimonials
  5. Preview: Show example newsletter content

Conclusion

Building a newsletter system goes beyond just collecting email addresses. By focusing on type safety, code organization, user experience, and robust error handling, we've created a system that's both functional and delightful to use.

Check out the NewsletterForm component and API route for the implementation details. Feel free to use it as inspiration for your own projects!

Want to see it in action? Just scroll down to the bottom of this page to try the newsletter subscription form yourself! 🚀

This blog post was co-created with Cursor AI