Newsletter Signup with Form Handling
Build a newsletter signup form with React that integrates with Shopify customer API. Learn proper form state management, validation, and error handling patterns.
Newsletter signups are essential for e-commerce—they let you build direct relationships with customers. Let’s build a robust newsletter form with proper validation, loading states, and Shopify API integration.
Theme Integration
The newsletter form is typically placed in the footer or as a standalone section:
snippets/footer-data.liquid (included in sections/footer.liquid)└── <div id="footer-root"> └── Footer (React) └── FooterColumn (type: 'newsletter') └── NewsletterForm ← You are here
Or as a standalone section:sections/newsletter.liquid└── <div id="newsletter-root"> └── NewsletterForm (React) ← You are hereSee Footer Component for footer integration.
Data Source
| Prop/State | Source | Liquid Field |
|---|---|---|
title | JSON script element | section.settings.title |
description | JSON script element | section.settings.description |
buttonText | JSON script element | section.settings.button_text |
successMessage | JSON script element | section.settings.success_message |
email | Local state (user input) | - |
formState | Local state | - |
errorMessage | Local state / API response | - |
Note: The newsletter form props come from section settings. The form submission uses Shopify’s customer/contact form endpoint—no Liquid data is needed for the API call itself.
Newsletter Form Architecture
┌──────────────────────────────────────────────────────────────────┐│ NEWSLETTER FORM ││ ││ ┌────────────────────────────────────────────────────────────┐ ││ │ IDLE STATE │ ││ │ │ ││ │ Subscribe to our newsletter │ ││ │ Get updates on new products and exclusive offers. │ ││ │ │ ││ │ ┌───────────────────────────────────┐ ┌───────────────┐ │ ││ │ │ Enter your email │ │ Subscribe │ │ ││ │ └───────────────────────────────────┘ └───────────────┘ │ ││ └────────────────────────────────────────────────────────────┘ ││ ││ ┌────────────────────────────────────────────────────────────┐ ││ │ LOADING STATE │ ││ │ │ ││ │ ┌───────────────────────────────────┐ ┌───────────────┐ │ ││ │ │ [email protected] │ │ [spinning] │ │ ││ │ └───────────────────────────────────┘ └───────────────┘ │ ││ └────────────────────────────────────────────────────────────┘ ││ ││ ┌────────────────────────────────────────────────────────────┐ ││ │ SUCCESS STATE │ ││ │ │ ││ │ ✓ Thanks for subscribing! │ ││ │ Check your inbox for a confirmation email. │ ││ └────────────────────────────────────────────────────────────┘ ││ ││ ┌────────────────────────────────────────────────────────────┐ ││ │ ERROR STATE │ ││ │ │ ││ │ ┌───────────────────────────────────┐ ┌───────────────┐ │ ││ │ │ invalid-email │ │ Subscribe │ │ ││ │ └───────────────────────────────────┘ └───────────────┘ │ ││ │ ⚠ Please enter a valid email address. │ ││ └────────────────────────────────────────────────────────────┘ │└──────────────────────────────────────────────────────────────────┘NewsletterForm Component
import { useState, useCallback } from 'react';import { useNewsletterSubscribe } from '@/hooks/useNewsletterSubscribe';import styles from './NewsletterForm.module.css';
interface NewsletterFormProps { title?: string; description?: string; successMessage?: string; buttonText?: string;}
type FormState = 'idle' | 'loading' | 'success' | 'error';
/** * NewsletterForm handles email subscription. * Features: * - Email validation * - Loading state during submission * - Success and error feedback * - Accessible form markup */export function NewsletterForm({ title = 'Subscribe to our newsletter', description = 'Get updates on new products and exclusive offers.', successMessage = 'Thanks for subscribing! Check your inbox for a confirmation email.', buttonText = 'Subscribe',}: NewsletterFormProps) { const [email, setEmail] = useState(''); const [formState, setFormState] = useState<FormState>('idle'); const [errorMessage, setErrorMessage] = useState('');
// Custom hook for API integration. const { subscribe } = useNewsletterSubscribe();
// Validate email format. const validateEmail = (value: string): boolean => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(value); };
// Handle form submission. const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault();
// Validate email. if (!email.trim()) { setFormState('error'); setErrorMessage('Please enter your email address.'); return; }
if (!validateEmail(email)) { setFormState('error'); setErrorMessage('Please enter a valid email address.'); return; }
// Submit to API. setFormState('loading'); setErrorMessage('');
try { await subscribe(email); setFormState('success'); setEmail(''); } catch (error) { setFormState('error'); setErrorMessage( error instanceof Error ? error.message : 'Something went wrong. Please try again.' ); } }, [email, subscribe] );
// Handle input change. const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => { setEmail(e.target.value); // Clear error when user starts typing. if (formState === 'error') { setFormState('idle'); setErrorMessage(''); } };
// Show success state. if (formState === 'success') { return ( <div className={styles.container}> <div className={styles.success}> <SuccessIcon /> <p className={styles.successMessage}>{successMessage}</p> </div> </div> ); }
return ( <div className={styles.container}> {/* Header text */} {title && <h4 className={styles.title}>{title}</h4>} {description && <p className={styles.description}>{description}</p>}
{/* Signup form */} <form className={styles.form} onSubmit={handleSubmit} noValidate> <div className={styles.inputWrapper}> <label htmlFor="newsletter-email" className={styles.visuallyHidden}> Email address </label> <input id="newsletter-email" type="email" className={`${styles.input} ${formState === 'error' ? styles.inputError : ''}`} placeholder="Enter your email" value={email} onChange={handleEmailChange} disabled={formState === 'loading'} aria-invalid={formState === 'error'} aria-describedby={formState === 'error' ? 'newsletter-error' : undefined} autoComplete="email" />
<button type="submit" className={styles.button} disabled={formState === 'loading'} > {formState === 'loading' ? ( <LoadingSpinner /> ) : ( buttonText )} </button> </div>
{/* Error message */} {formState === 'error' && errorMessage && ( <p id="newsletter-error" className={styles.error} role="alert"> <ErrorIcon /> {errorMessage} </p> )} </form> </div> );}
/** * Success checkmark icon. */function SuccessIcon() { return ( <svg className={styles.successIcon} width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" > <circle cx="12" cy="12" r="10" /> <path d="m9 12 2 2 4-4" /> </svg> );}
/** * Error warning icon. */function ErrorIcon() { return ( <svg className={styles.errorIcon} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" > <circle cx="12" cy="12" r="10" /> <line x1="12" y1="8" x2="12" y2="12" /> <line x1="12" y1="16" x2="12.01" y2="16" /> </svg> );}
/** * Loading spinner for submit button. */function LoadingSpinner() { return ( <svg className={styles.spinner} width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" > <circle cx="12" cy="12" r="10" opacity="0.25" /> <path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" /> </svg> );}NewsletterForm Styles
.container { max-width: 400px;}
.title { margin: 0 0 0.5rem; font-size: 1rem; font-weight: 600; color: var(--color-text);}
.description { margin: 0 0 1rem; font-size: 0.875rem; color: var(--color-text-muted); line-height: 1.5;}
/* Form layout */.form { display: flex; flex-direction: column; gap: 0.5rem;}
.inputWrapper { display: flex; gap: 0.5rem;}
/* Input field */.input { flex: 1; min-width: 0; padding: 0.75rem 1rem; border: 1px solid var(--color-border); border-radius: var(--radius-sm); background-color: var(--color-background); color: var(--color-text); font-size: 0.9375rem; transition: border-color 0.15s ease, box-shadow 0.15s ease;}
.input::placeholder { color: var(--color-text-muted);}
.input:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.15);}
.input:disabled { opacity: 0.6; cursor: not-allowed;}
.inputError { border-color: var(--color-error, #dc2626);}
.inputError:focus { border-color: var(--color-error, #dc2626); box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);}
/* Submit button */.button { flex-shrink: 0; display: flex; align-items: center; justify-content: center; min-width: 100px; padding: 0.75rem 1.25rem; border: none; border-radius: var(--radius-sm); background-color: var(--color-primary); color: var(--color-primary-foreground, #fff); font-size: 0.9375rem; font-weight: 500; cursor: pointer; transition: background-color 0.15s ease, opacity 0.15s ease;}
.button:hover:not(:disabled) { background-color: var(--color-primary-hover);}
.button:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px;}
.button:disabled { opacity: 0.7; cursor: not-allowed;}
/* Loading spinner */.spinner { animation: spin 1s linear infinite;}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); }}
/* Error message */.error { display: flex; align-items: center; gap: 0.375rem; margin: 0; font-size: 0.8125rem; color: var(--color-error, #dc2626);}
.errorIcon { flex-shrink: 0;}
/* Success state */.success { display: flex; align-items: flex-start; gap: 0.75rem; padding: 1rem; background-color: var(--color-success-bg, #f0fdf4); border: 1px solid var(--color-success-border, #bbf7d0); border-radius: var(--radius-sm);}
.successIcon { flex-shrink: 0; color: var(--color-success, #16a34a);}
.successMessage { margin: 0; font-size: 0.9375rem; color: var(--color-success-text, #166534); line-height: 1.5;}
/* Visually hidden label for accessibility */.visuallyHidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;}
/* Responsive: stack on small screens */@media (max-width: 480px) { .inputWrapper { flex-direction: column; }
.button { width: 100%; }}Newsletter Subscribe Hook
import { useCallback, useState } from 'react';
interface UseNewsletterSubscribeReturn { subscribe: (email: string) => Promise<void>; isLoading: boolean; error: string | null;}
/** * Custom hook for newsletter subscription. * Handles API communication with Shopify customer endpoint. */export function useNewsletterSubscribe(): UseNewsletterSubscribeReturn { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<string | null>(null);
const subscribe = useCallback(async (email: string) => { setIsLoading(true); setError(null);
try { // Shopify's customer creation endpoint for newsletter signups. const response = await fetch('/contact#contact_form', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ 'form_type': 'customer', 'utf8': '✓', 'customer[email]': email, 'customer[accepts_marketing]': 'true', }), });
// Shopify returns a redirect on success, so we check for that. if (!response.ok && response.status !== 302) { // Check if it's a duplicate email. const text = await response.text(); if (text.includes('email has already been taken')) { throw new Error('This email is already subscribed.'); } throw new Error('Failed to subscribe. Please try again.'); } } catch (err) { const message = err instanceof Error ? err.message : 'Failed to subscribe.'; setError(message); throw err; } finally { setIsLoading(false); } }, []);
return { subscribe, isLoading, error };}Alternative: Using Shopify AJAX API
import { useCallback } from 'react';
/** * Alternative newsletter subscription using Shopify's * customer marketing API (requires app or custom endpoint). */export function useNewsletterSubscribeAjax() { const subscribe = useCallback(async (email: string) => { // Option 1: Use a Shopify app's endpoint. // Many newsletter apps provide an API endpoint. const response = await fetch('/apps/newsletter/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email }), });
if (!response.ok) { const data = await response.json(); throw new Error(data.message || 'Failed to subscribe.'); }
return response.json(); }, []);
return { subscribe };}Form with Honeypot Spam Protection
import { useState, useCallback } from 'react';import styles from './NewsletterForm.module.css';
/** * Newsletter form with honeypot field for spam protection. * Bots will fill in the hidden field, revealing themselves. */export function NewsletterFormProtected() { const [email, setEmail] = useState(''); const [honeypot, setHoneypot] = useState(''); const [formState, setFormState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault();
// If honeypot is filled, it's likely a bot. // Silently "succeed" without actually submitting. if (honeypot) { setFormState('success'); return; }
// Proceed with real submission... setFormState('loading');
try { // Submit logic here... setFormState('success'); } catch { setFormState('error'); } };
return ( <form onSubmit={handleSubmit}> {/* Real email input */} <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Enter your email" required />
{/* Honeypot field - hidden from real users */} <input type="text" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} tabIndex={-1} autoComplete="off" style={{ position: 'absolute', left: '-9999px', opacity: 0, height: 0, width: 0, }} aria-hidden="true" />
<button type="submit" disabled={formState === 'loading'}> Subscribe </button> </form> );}Liquid Section Integration
{% comment %} sections/newsletter.liquid {% endcomment %}{% comment %} Newsletter section that can be used in footer or standalone.{% endcomment %}
{%- style -%} #Newsletter-{{ section.id }} { padding-top: {{ section.settings.padding_top }}px; padding-bottom: {{ section.settings.padding_bottom }}px; }{%- endstyle -%}
<div id="Newsletter-{{ section.id }}" class="newsletter-section"> <div id="newsletter-form-root" data-section-id="{{ section.id }}"></div></div>
<script type="application/json" id="newsletter-settings-{{ section.id }}"> { "title": {{ section.settings.title | json }}, "description": {{ section.settings.description | json }}, "buttonText": {{ section.settings.button_text | json }}, "successMessage": {{ section.settings.success_message | json }} }</script>
{% schema %}{ "name": "Newsletter", "settings": [ { "type": "text", "id": "title", "label": "Heading", "default": "Subscribe to our newsletter" }, { "type": "textarea", "id": "description", "label": "Description", "default": "Get updates on new products and exclusive offers." }, { "type": "text", "id": "button_text", "label": "Button text", "default": "Subscribe" }, { "type": "text", "id": "success_message", "label": "Success message", "default": "Thanks for subscribing!" }, { "type": "range", "id": "padding_top", "min": 0, "max": 100, "step": 4, "default": 40, "label": "Padding top" }, { "type": "range", "id": "padding_bottom", "min": 0, "max": 100, "step": 4, "default": 40, "label": "Padding bottom" } ], "presets": [ { "name": "Newsletter" } ]}{% endschema %}Testing Form States
// Use this for Storybook or visual testing.
import { NewsletterForm } from './NewsletterForm';
export const Default = () => <NewsletterForm />;
export const CustomText = () => ( <NewsletterForm title="Join the club" description="Be the first to know about sales and new arrivals." buttonText="Sign up" successMessage="You're in! Check your email." />);
export const InFooter = () => ( <div style={{ maxWidth: '300px', padding: '20px', background: '#f5f5f5' }}> <NewsletterForm title="Stay updated" description="" buttonText="Go" /> </div>);Key Takeaways
- Form state machine: Track idle, loading, success, and error states explicitly
- Client-side validation: Validate email format before submission to reduce API calls
- Accessible forms: Use proper labels, ARIA attributes, and error announcements
- Loading feedback: Show spinner and disable inputs during submission
- Error handling: Display clear error messages with visual indicators
- Success state: Confirm subscription and provide next steps
- Spam protection: Consider honeypot fields for bot prevention
- Shopify integration: Use customer form endpoint or newsletter app APIs
In the next lesson, we’ll build reusable content section components for rich text and image layouts.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...