Footer and Content Components Intermediate 10 min read

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 here

See Footer Component for footer integration.

Data Source

Prop/StateSourceLiquid Field
titleJSON script elementsection.settings.title
descriptionJSON script elementsection.settings.description
buttonTextJSON script elementsection.settings.button_text
successMessageJSON script elementsection.settings.success_message
emailLocal state (user input)-
formStateLocal state-
errorMessageLocal 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

src/components/footer/NewsletterForm/NewsletterForm.tsx
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

src/components/footer/NewsletterForm/NewsletterForm.module.css
.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

src/hooks/useNewsletterSubscribe.ts
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

src/hooks/useNewsletterSubscribeAjax.ts
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

src/components/footer/NewsletterForm/NewsletterFormProtected.tsx
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

src/components/footer/NewsletterForm/NewsletterForm.stories.tsx
// 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

  1. Form state machine: Track idle, loading, success, and error states explicitly
  2. Client-side validation: Validate email format before submission to reduce API calls
  3. Accessible forms: Use proper labels, ARIA attributes, and error announcements
  4. Loading feedback: Show spinner and disable inputs during submission
  5. Error handling: Display clear error messages with visual indicators
  6. Success state: Confirm subscription and provide next steps
  7. Spam protection: Consider honeypot fields for bot prevention
  8. 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...