Login and Registration Forms
Build secure, accessible login and registration forms with React. Learn form state management, validation patterns, and Shopify customer authentication integration.
Customer authentication is the gateway to personalized shopping experiences. When customers can log in, they get faster checkout with saved addresses, order history visibility, and personalized recommendations. In this lesson, we’ll build robust login and registration forms with React that integrate seamlessly with Shopify’s customer authentication system.
How Shopify Authentication Works
Before we write any code, it’s important to understand how Shopify handles customer authentication. Unlike some platforms where you might build a custom authentication API, Shopify provides built-in endpoints that handle all the security concerns for you:
/account/login- Accepts POST requests with email and password, creates a session cookie on success/account- Accepts POST requests to create new customer accounts/account/recover- Sends password reset emails to customers
This means our React forms will submit directly to these Shopify endpoints using standard HTML form submission. We’re not building a custom API—we’re enhancing the user experience with client-side validation and better UI feedback while letting Shopify handle the actual authentication.
Theme Integration
The authentication forms integrate with Shopify’s customer templates:
templates/customers/login.liquid└── sections/customer-login.liquid └── <div id="auth-root"> └── CustomerAuth (React) ├── AuthTabs ← Toggle login/register ├── LoginForm ← You are here ├── RegistrationForm └── RecoverPasswordFormData We Need from Liquid
Our React components need a few pieces of information from Shopify:
| Prop/State | Source | Purpose |
|---|---|---|
returnUrl | request.path | Where to redirect after login |
defaultView | Section setting | Show login or register first |
formErrors | form.errors | Server-side validation errors |
email | User input | Controlled form field |
password | User input | Controlled form field |
formState | Local state | Track idle/loading/error states |
Note: Shopify handles authentication server-side. Our React forms submit to Shopify’s standard customer endpoints. We’re adding client-side validation and UI polish, not replacing Shopify’s auth system.
The Form State Machine Pattern
Before diving into components, let’s talk about how we’ll manage form state. Forms have multiple states that affect the UI:
- Idle - Waiting for user input, submit button enabled
- Loading - Form submitted, waiting for response, inputs disabled
- Success - Operation completed (mainly for password recovery)
- Error - Validation failed or server returned an error
Rather than tracking multiple boolean flags (isLoading, hasError, isSuccess), we use a single state variable that can only be one value at a time. This prevents impossible states like being both loading and showing an error.
type AuthFormState = 'idle' | 'loading' | 'success' | 'error';This pattern makes our UI logic much simpler: instead of checking if (isLoading && !hasError), we just check if (formState === 'loading').
Authentication Form Architecture
Here’s the visual structure we’re building:
┌────────────────────────────────────────────────────────────────────────────┐│ CUSTOMER AUTH SECTION ││ ││ ┌──────────────────────────────────────────────────────────────────────┐ ││ │ AuthTabs │ ││ │ ┌─────────────────┐ ┌─────────────────┐ │ ││ │ │ Login │ │ Register │ │ ││ │ │ (active) │ │ │ │ ││ │ └─────────────────┘ └─────────────────┘ │ ││ └──────────────────────────────────────────────────────────────────────┘ ││ ││ ┌──────────────────────────────────────────────────────────────────────┐ ││ │ LoginForm │ ││ │ │ ││ │ ┌────────────────────────────────────────────────────────────────┐ │ ││ │ │ Email │ │ ││ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ ││ │ │ │ [email protected] │ │ │ ││ │ │ └──────────────────────────────────────────────────────────┘ │ │ ││ │ └────────────────────────────────────────────────────────────────┘ │ ││ │ │ ││ │ ┌────────────────────────────────────────────────────────────────┐ │ ││ │ │ Password │ │ ││ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ ││ │ │ │ •••••••••••• [show] │ │ │ ││ │ │ └──────────────────────────────────────────────────────────┘ │ │ ││ │ └────────────────────────────────────────────────────────────────┘ │ ││ │ │ ││ │ ┌────────────────────────────────────────────────────────────────┐ │ ││ │ │ Sign In │ │ ││ │ └────────────────────────────────────────────────────────────────┘ │ ││ │ │ ││ │ Forgot your password? │ ││ └──────────────────────────────────────────────────────────────────────┘ │└────────────────────────────────────────────────────────────────────────────┘The tabs allow switching between login and registration without a page reload. The “Forgot your password?” link switches to a password recovery form. This creates a smooth, single-page experience while still using Shopify’s server-side authentication.
TypeScript Interfaces
Let’s define our types first. Good TypeScript interfaces make our components self-documenting and catch errors at compile time:
/** * Form state machine states. * Using a union type ensures we can only be in one state at a time. */type AuthFormState = 'idle' | 'loading' | 'success' | 'error';
/** * Settings passed from Liquid via JSON script tag. * These configure the initial behavior of the auth forms. */interface AuthSettings { returnUrl: string; // Where to redirect after login defaultView: 'login' | 'register'; // Which form to show first showTabs: boolean; // Whether to show login/register tabs formErrors: string[]; // Server-side errors from previous submission}The Container Component
The CustomerAuth component acts as a controller, managing which form is currently visible. It doesn’t contain any form logic itself—it just handles view switching:
import { useState } from 'react';import { AuthTabs } from './AuthTabs';import { LoginForm } from './LoginForm';import { RegistrationForm } from './RegistrationForm';import { RecoverPasswordForm } from './RecoverPasswordForm';import styles from './CustomerAuth.module.css';
type AuthView = 'login' | 'register' | 'recover';
interface CustomerAuthProps { settings: AuthSettings;}
/** * CustomerAuth manages which authentication form is visible. * It handles switching between login, register, and password recovery views. */export function CustomerAuth({ settings }: CustomerAuthProps) { // Track which view is active. Initialize from settings. const [view, setView] = useState<AuthView>(settings.defaultView);
return ( <div className={styles.container}> {/* Only show tabs when not in password recovery mode */} {settings.showTabs && view !== 'recover' && ( <AuthTabs activeTab={view as 'login' | 'register'} onTabChange={(tab) => setView(tab)} /> )}
{/* Render the appropriate form based on current view */} <div className={styles.formContainer}> {view === 'login' && ( <LoginForm returnUrl={settings.returnUrl} formErrors={settings.formErrors} onRecoverClick={() => setView('recover')} /> )}
{view === 'register' && ( <RegistrationForm returnUrl={settings.returnUrl} formErrors={settings.formErrors} /> )}
{view === 'recover' && ( <RecoverPasswordForm onBackToLogin={() => setView('login')} /> )} </div> </div> );}Notice how the container doesn’t know anything about form validation or submission. Each form component handles its own logic. This separation makes the code easier to test and maintain.
Building the Tab Navigation
The tabs use proper ARIA roles for accessibility. Screen readers will announce these as tabs, and the visual styling indicates which is active:
import styles from './AuthTabs.module.css';
interface AuthTabsProps { activeTab: 'login' | 'register'; onTabChange: (tab: 'login' | 'register') => void;}
/** * AuthTabs provides accessible toggle between login and registration. * Uses proper ARIA roles so screen readers announce it as a tab interface. */export function AuthTabs({ activeTab, onTabChange }: AuthTabsProps) { return ( <div className={styles.tabs} role="tablist"> <button role="tab" aria-selected={activeTab === 'login'} className={`${styles.tab} ${activeTab === 'login' ? styles.active : ''}`} onClick={() => onTabChange('login')} > Sign In </button> <button role="tab" aria-selected={activeTab === 'register'} className={`${styles.tab} ${activeTab === 'register' ? styles.active : ''}`} onClick={() => onTabChange('register')} > Create Account </button> </div> );}The styles use an underline indicator for the active tab. The negative margin trick aligns the border with the container’s border for a polished look:
.tabs { display: flex; border-bottom: 1px solid var(--color-border); margin-bottom: 2rem;}
.tab { flex: 1; padding: 1rem; border: none; background: none; font-size: 1rem; font-weight: 500; color: var(--color-text-muted); cursor: pointer; transition: color 0.15s ease; border-bottom: 2px solid transparent; margin-bottom: -1px; /* Align with container border */}
.tab:hover { color: var(--color-text);}
.tab.active { color: var(--color-text); border-bottom-color: var(--color-primary);}The Login Form
Now for the main event. The login form needs to:
- Collect email and password
- Validate before submission (to give instant feedback)
- Show loading state while submitting
- Display errors (both client-side validation and server errors)
- Submit to Shopify’s
/account/loginendpoint
Here’s the key insight: we use a regular HTML form with method="post" and action="/account/login". React handles the UI, but the actual submission uses the browser’s native form handling. This means Shopify receives a standard form POST, which it knows how to process.
import { useState, useCallback } from 'react';import { FormInput } from './FormInput';import styles from './AuthForm.module.css';
interface LoginFormProps { returnUrl: string; formErrors: string[]; // Errors from previous server response onRecoverClick: () => void; // Callback to switch to password recovery}
type FormState = 'idle' | 'loading' | 'error';
export function LoginForm({ returnUrl, formErrors, onRecoverClick }: LoginFormProps) { // Form field state const [email, setEmail] = useState(''); const [password, setPassword] = useState('');
// UI state - initialize to 'error' if server returned errors const [formState, setFormState] = useState<FormState>( formErrors.length > 0 ? 'error' : 'idle' ); const [errors, setErrors] = useState<string[]>(formErrors);
/** * Client-side validation runs before form submission. * Returns true if valid, false if errors were found. */ const validateForm = (): boolean => { const newErrors: string[] = [];
// Email validation if (!email.trim()) { newErrors.push('Email is required.'); } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { newErrors.push('Please enter a valid email address.'); }
// Password validation (just checking it exists for login) if (!password) { newErrors.push('Password is required.'); }
if (newErrors.length > 0) { setErrors(newErrors); setFormState('error'); return false; }
return true; };
/** * Form submission handler. * If validation passes, we let the form submit normally to Shopify. * If validation fails, we prevent submission and show errors. */ const handleSubmit = useCallback( (e: React.FormEvent<HTMLFormElement>) => { if (!validateForm()) { e.preventDefault(); // Stop form submission return; } // Validation passed - show loading state // The form will submit to Shopify and the page will redirect setFormState('loading'); }, [email, password] );
/** * Clear errors when user starts typing. * This provides immediate feedback that their input is being accepted. */ const handleInputChange = (setter: (value: string) => void) => { return (e: React.ChangeEvent<HTMLInputElement>) => { setter(e.target.value); if (formState === 'error') { setFormState('idle'); setErrors([]); } }; };
return ( <form className={styles.form} method="post" action="/account/login" onSubmit={handleSubmit} noValidate // Disable browser validation, we handle it ourselves > <h2 className={styles.heading}>Sign In</h2>
{/* Error message box */} {formState === 'error' && errors.length > 0 && ( <div className={styles.errorBox} role="alert"> <ErrorIcon /> <ul className={styles.errorList}> {errors.map((error, index) => ( <li key={index}>{error}</li> ))} </ul> </div> )}
{/* Hidden field tells Shopify where to redirect after login */} <input type="hidden" name="return_url" value={returnUrl} />
{/* Email field - name must be "customer[email]" for Shopify */} <FormInput id="customer-email" name="customer[email]" type="email" label="Email" value={email} onChange={handleInputChange(setEmail)} autoComplete="email" required disabled={formState === 'loading'} />
{/* Password field - name must be "customer[password]" for Shopify */} <FormInput id="customer-password" name="customer[password]" type="password" label="Password" value={password} onChange={handleInputChange(setPassword)} autoComplete="current-password" required disabled={formState === 'loading'} />
{/* Submit button with loading state */} <button type="submit" className={styles.submitButton} disabled={formState === 'loading'} > {formState === 'loading' ? ( <> <LoadingSpinner /> Signing in... </> ) : ( 'Sign In' )} </button>
{/* Password recovery link */} <button type="button" className={styles.linkButton} onClick={onRecoverClick} > Forgot your password? </button> </form> );}A few important details:
-
Field names matter: Shopify expects
customer[email]andcustomer[password]. Using different names will cause the form to fail. -
noValidateattribute: This disables the browser’s built-in validation. We want to show our own styled error messages, not the browser’s default popups. -
Hidden
return_urlfield: This tells Shopify where to redirect after successful login. Without it, customers might land somewhere unexpected.
The Reusable FormInput Component
Rather than repeating input markup in every form, we create a reusable component. This ensures consistent styling and behavior, including the password visibility toggle:
import { useState } from 'react';import styles from './FormInput.module.css';
interface FormInputProps { id: string; name: string; type: 'text' | 'email' | 'password'; label: string; value: string; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; autoComplete?: string; required?: boolean; disabled?: boolean; hint?: string;}
/** * FormInput provides a labeled input with optional password toggle. * Handles accessibility attributes automatically. */export function FormInput({ id, name, type, label, value, onChange, autoComplete, required, disabled, hint,}: FormInputProps) { // For password fields, track whether to show plain text const [showPassword, setShowPassword] = useState(false); const inputType = type === 'password' && showPassword ? 'text' : type;
return ( <div className={styles.field}> <label htmlFor={id} className={styles.label}> {label} {required && <span className={styles.required}>*</span>} </label>
<div className={styles.inputWrapper}> <input id={id} name={name} type={inputType} value={value} onChange={onChange} autoComplete={autoComplete} required={required} disabled={disabled} className={styles.input} aria-describedby={hint ? `${id}-hint` : undefined} />
{/* Password visibility toggle */} {type === 'password' && ( <button type="button" className={styles.togglePassword} onClick={() => setShowPassword(!showPassword)} aria-label={showPassword ? 'Hide password' : 'Show password'} > {showPassword ? <EyeOffIcon /> : <EyeIcon />} </button> )} </div>
{hint && ( <p id={`${id}-hint`} className={styles.hint}>{hint}</p> )} </div> );}The password toggle is important for usability. Users often make typos in passwords, especially on mobile. Letting them verify what they typed reduces frustration.
Registration Form
The registration form follows the same patterns but collects more fields. Shopify requires at minimum an email and password, but we also collect first and last name for personalization:
export function RegistrationForm({ returnUrl, formErrors }: RegistrationFormProps) { const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [acceptsMarketing, setAcceptsMarketing] = useState(false); // ... state management same as LoginForm
const validateForm = (): boolean => { const newErrors: string[] = [];
if (!firstName.trim()) newErrors.push('First name is required.'); if (!lastName.trim()) newErrors.push('Last name is required.');
if (!email.trim()) { newErrors.push('Email is required.'); } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { newErrors.push('Please enter a valid email address.'); }
// Shopify requires minimum 5 characters for passwords if (!password) { newErrors.push('Password is required.'); } else if (password.length < 5) { newErrors.push('Password must be at least 5 characters.'); }
// ... error handling same as LoginForm };
return ( <form method="post" action="/account" onSubmit={handleSubmit} noValidate> <h2>Create Account</h2>
{/* Name fields side by side on desktop */} <div className={styles.fieldRow}> <FormInput name="customer[first_name]" label="First Name" // ... other props /> <FormInput name="customer[last_name]" label="Last Name" // ... other props /> </div>
<FormInput name="customer[email]" type="email" label="Email" /> <FormInput name="customer[password]" type="password" label="Password" hint="Must be at least 5 characters" />
{/* Marketing opt-in checkbox */} <label className={styles.checkbox}> <input type="checkbox" name="customer[accepts_marketing]" checked={acceptsMarketing} onChange={(e) => setAcceptsMarketing(e.target.checked)} /> <span>Sign up for emails about new products and offers</span> </label>
<button type="submit">Create Account</button> </form> );}The marketing opt-in checkbox is important for compliance with email marketing laws. When checked, Shopify records that the customer consented to receive marketing emails.
Password Recovery
The password recovery form works slightly differently. Instead of letting the browser submit the form (which would cause a page redirect), we use fetch() to submit in the background. This lets us show a success message without leaving the page:
export function RecoverPasswordForm({ onBackToLogin }: Props) { const [email, setEmail] = useState(''); const [formState, setFormState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // Always prevent default for this form
if (!validateEmail()) return;
setFormState('loading');
try { const formData = new FormData(); formData.append('form_type', 'recover_customer_password'); formData.append('utf8', '✓'); formData.append('email', email);
await fetch('/account/recover', { method: 'POST', body: formData, });
// Shopify always returns success (even for non-existent emails) // This is a security feature to prevent email enumeration setFormState('success'); } catch (error) { setFormState('error'); } };
// Show success message instead of form after submission if (formState === 'success') { return ( <div className={styles.successBox}> <SuccessIcon /> <h2>Check your email</h2> <p> If an account exists for {email}, you'll receive a password reset link shortly. </p> <button onClick={onBackToLogin}>Return to sign in</button> </div> ); }
return ( <form onSubmit={handleSubmit}> <h2>Reset Password</h2> <p>Enter your email and we'll send you a reset link.</p> <FormInput name="email" type="email" label="Email" /> <button type="submit">Send Reset Link</button> <button type="button" onClick={onBackToLogin}>Back to sign in</button> </form> );}Notice the security-conscious success message: “If an account exists…” This matches what Shopify does—it never confirms whether an email exists in the system, which prevents attackers from discovering valid customer emails.
Passing Data from Liquid to React
The final piece is the Liquid template that renders the mounting point and passes configuration to React:
{% comment %} sections/customer-login.liquid {% endcomment %}
<div id="auth-root"></div>
<script type="application/json" id="auth-settings"> { "returnUrl": {{ request.path | default: '/account' | json }}, "defaultView": {{ section.settings.default_view | json }}, "showTabs": {{ section.settings.show_tabs | json }}, "formErrors": [ {%- if form.errors -%} {%- for error in form.errors -%} {{ form.errors.messages[error] | json }}{% unless forloop.last %},{% endunless %} {%- endfor -%} {%- endif -%} ] }</script>
{% schema %}{ "name": "Customer Login", "settings": [ { "type": "select", "id": "default_view", "label": "Default view", "options": [ { "value": "login", "label": "Login" }, { "value": "register", "label": "Register" } ], "default": "login" }, { "type": "checkbox", "id": "show_tabs", "label": "Show login/register tabs", "default": true } ]}{% endschema %}The form.errors check is important. When a customer submits the form and Shopify returns an error (like “incorrect password”), the page reloads with form.errors populated. We pass these to React so they appear in our styled error box, not Shopify’s default error display.
Mounting the React Components
The entry point script parses the JSON settings and mounts the React app:
import { createRoot } from 'react-dom/client';import { CustomerAuth } from '@/components/customer/CustomerAuth';
export function mountCustomerAuth() { const root = document.getElementById('auth-root'); const settingsScript = document.getElementById('auth-settings');
if (!root || !settingsScript) return;
try { const settings = JSON.parse(settingsScript.textContent || '{}'); createRoot(root).render(<CustomerAuth settings={settings} />); } catch (error) { console.error('Failed to mount customer auth:', error); }}
// Mount when DOM is readyif (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mountCustomerAuth);} else { mountCustomerAuth();}Key Takeaways
-
Use Shopify’s built-in auth endpoints: Don’t reinvent authentication. Submit to
/account/login,/account, and/account/recover. -
Form state machine pattern: Track form state as a single value (
idle,loading,success,error) instead of multiple booleans. -
Client-side validation: Validate before submission for instant feedback, but don’t rely on it for security—Shopify validates server-side too.
-
Correct field names: Shopify expects
customer[email],customer[password], etc. Wrong names = broken forms. -
Handle server errors: Pass
form.errorsfrom Liquid to React so server-side validation errors appear in your styled UI. -
Password visibility toggle: Let users see what they typed. It reduces errors and frustration.
-
Accessible markup: Use proper labels, ARIA attributes, and
role="alert"for error messages. -
Marketing opt-in: Include the checkbox for email marketing compliance.
In the next lesson, we’ll build the account dashboard that customers see after logging in.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...