Customer Account Components Intermediate 12 min read

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
└── RecoverPasswordForm

Data We Need from Liquid

Our React components need a few pieces of information from Shopify:

Prop/StateSourcePurpose
returnUrlrequest.pathWhere to redirect after login
defaultViewSection settingShow login or register first
formErrorsform.errorsServer-side validation errors
emailUser inputControlled form field
passwordUser inputControlled form field
formStateLocal stateTrack 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:

src/types/customer.ts
/**
* 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:

src/components/customer/CustomerAuth/CustomerAuth.tsx
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:

src/components/customer/CustomerAuth/AuthTabs.tsx
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:

src/components/customer/CustomerAuth/AuthTabs.module.css
.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:

  1. Collect email and password
  2. Validate before submission (to give instant feedback)
  3. Show loading state while submitting
  4. Display errors (both client-side validation and server errors)
  5. Submit to Shopify’s /account/login endpoint

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.

src/components/customer/CustomerAuth/LoginForm.tsx
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:

  1. Field names matter: Shopify expects customer[email] and customer[password]. Using different names will cause the form to fail.

  2. noValidate attribute: This disables the browser’s built-in validation. We want to show our own styled error messages, not the browser’s default popups.

  3. Hidden return_url field: 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:

src/components/customer/CustomerAuth/FormInput.tsx
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:

src/components/customer/CustomerAuth/RegistrationForm.tsx
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:

src/components/customer/CustomerAuth/RecoverPasswordForm.tsx
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:

src/entries/customer-auth.tsx
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 ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountCustomerAuth);
} else {
mountCustomerAuth();
}

Key Takeaways

  1. Use Shopify’s built-in auth endpoints: Don’t reinvent authentication. Submit to /account/login, /account, and /account/recover.

  2. Form state machine pattern: Track form state as a single value (idle, loading, success, error) instead of multiple booleans.

  3. Client-side validation: Validate before submission for instant feedback, but don’t rely on it for security—Shopify validates server-side too.

  4. Correct field names: Shopify expects customer[email], customer[password], etc. Wrong names = broken forms.

  5. Handle server errors: Pass form.errors from Liquid to React so server-side validation errors appear in your styled UI.

  6. Password visibility toggle: Let users see what they typed. It reduces errors and frustration.

  7. Accessible markup: Use proper labels, ARIA attributes, and role="alert" for error messages.

  8. 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...