Building Custom Blocks
Learn how to create custom WordPress® blocks that extend Commerce Connect functionality.
Overview
Section titled “Overview”Commerce Connect uses the WordPress® Block Editor (Gutenberg) architecture. Custom blocks can:
- Display product data from BigCommerce
- Integrate with Commerce Connect services (cart, checkout, customer auth)
- Customize storefront UI with full design control
- Leverage Commerce Connect’s state management and settings system
This guide is based on the complete implementation of the Lost Password block and other Commerce Connect blocks.
Block Architecture
Section titled “Block Architecture”Component Structure
Section titled “Component Structure”blocks/your-block-name/├── block.json # WordPress® block metadata├── index.jsx # Block registration (editor)├── rootElement.jsx # Save function (frontend mount point)├── style.scss # Frontend styles├── view.jsx # Frontend initialization├── components/│ └── YourBlock.jsx # Main React component├── controls/│ └── index.jsx # Editor sidebar controls└── services/ ├── block-service.js # API integration └── __tests__/ └── block-service.test.jsStep-by-Step Implementation
Section titled “Step-by-Step Implementation”1. Block Configuration
Section titled “1. Block Configuration”Create block.json
Section titled “Create block.json”WordPress® block metadata file:
{ "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 3, "name": "commerce-connect/your-block-name", "version": "0.1.0", "title": "Your Block Display Name", "category": "commerce-connect", "icon": "cart", "description": "Description shown in block inserter.", "example": {}, "supports": { "html": false }, "attributes": { "settingsId": { "type": "string", "default": "" } }, "textdomain": "commerce-connect", "editorScript": "file:./index.js", "editorStyle": "file:./index.css", "style": "file:./style-index.css", "viewScript": "file:./view.js"}Key Fields:
name: Must usecommerce-connect/namespacecategory: Usecommerce-connectfor automatic groupingattributes.settingsId: Required for Commerce Connect settings integrationviewScript: Loaded on frontend for interactivity
2. Block Registration
Section titled “2. Block Registration”Create index.jsx
Section titled “Create index.jsx”Register the block with WordPress®:
import { registerBlockType } from '@wordpress/blocks';import metadata from './block.json';import RootElement from './rootElement';import BlockProvider from '../_state/editor/provider';import { useBlockProps } from '@wordpress/block-editor';import YourBlockControls from './controls';import YourBlockComponent from './components/YourBlock';import './style.scss';
registerBlockType(metadata.name, { edit: (props) => { return ( <div {...useBlockProps()}> <BlockProvider blockName="yourBlockName" blockProps={props}> <YourBlockControls /> <YourBlockComponent /> </BlockProvider> </div> ); }, save: (props) => { const blockProps = useBlockProps.save(); return ( <div {...blockProps}> <RootElement {...props} /> </div> ); },});Key Patterns:
BlockProviderwraps the editor previewuseBlockProps()provides required wrapper attributes- Edit function renders controls + preview
- Save function creates frontend mount point
Create rootElement.jsx
Section titled “Create rootElement.jsx”Frontend mounting point:
export default function RootElement(props) { return ( <div data-commerce-connect-block-type="your-block-name" data-commerce-connect-block-settings-id={props.attributes.settingsId} /> );}This empty div becomes the React app mount point on the frontend.
3. Frontend Integration
Section titled “3. Frontend Integration”Create view.jsx
Section titled “Create view.jsx”Initialize React app on frontend:
import { Suspense, createRoot } from '@wordpress/element';import { SettingsProvider } from '@wpengine/ecom-ui';import YourBlock from './components/YourBlock';import { decodeSettings } from '@wpengine/ecom-ui';
document.addEventListener('DOMContentLoaded', function () { const blockElements = document.querySelectorAll('[data-commerce-connect-block-type="your-block-name"]');
blockElements.forEach((block) => { const settingsId = block.getAttribute('data-commerce-connect-block-settings-id'); const settings = decodeSettings(settingsId); const root = createRoot(block);
root.render( <Suspense fallback={<div>Loading...</div>}> <SettingsProvider componentType="yourBlockName" blockName="yourBlockName" settings={settings}> <YourBlock /> </SettingsProvider> </Suspense>, ); });});Key Points:
- Finds all instances of the block on the page
- Decodes settings from base64 attribute
- Wraps component in SettingsProvider for configuration access
- Uses Suspense for lazy loading
4. Main Component
Section titled “4. Main Component”Create components/YourBlock.jsx
Section titled “Create components/YourBlock.jsx”The main React component:
import React, { useState, useEffect } from 'react';import { useSettingsState } from '@wpengine/ecom-ui';import { getInstance as getServiceInstance } from '../services/block-service';
const YourBlock = () => { const settings = useSettingsState() || {};
// State management const [formData, setFormData] = useState({ email: '', // ... other fields }); const [isSubmitting, setIsSubmitting] = useState(false); const [message, setMessage] = useState({ type: null, text: '' }); const [errors, setErrors] = useState({});
// Event handlers const handleInputChange = (field) => (e) => { setFormData((prev) => ({ ...prev, [field]: e.target.value }));
// Clear field error on change if (errors[field]) { setErrors((prev) => { const updated = { ...prev }; delete updated[field]; return updated; }); } };
const validateForm = () => { const newErrors = {};
if (!formData.email) { newErrors.email = 'Email is required'; } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { newErrors.email = 'Invalid email format'; }
setErrors(newErrors); return Object.keys(newErrors).length === 0; };
const handleSubmit = async (e) => { e.preventDefault();
if (!validateForm()) { return; }
setIsSubmitting(true); setMessage({ type: null, text: '' });
try { const service = getServiceInstance(); const result = await service.performAction(formData);
setMessage({ type: 'success', text: result.message || 'Success!', });
// Reset form setFormData({ email: '' }); } catch (error) { setMessage({ type: 'error', text: error.message || 'An error occurred', }); } finally { setIsSubmitting(false); } };
return ( <div className="commerce-connect-your-block"> <form onSubmit={handleSubmit}> <h2 style={{ fontSize: settings.titleFontSize, color: settings.titleColor, }}> {settings.titleText || 'Default Title'} </h2>
<div className="form-field"> <label htmlFor="email" style={{ fontSize: settings.labelFontSize, color: settings.labelColor, }}> Email Address </label>
<input id="email" type="email" value={formData.email} onChange={handleInputChange('email')} disabled={isSubmitting} style={{ borderColor: errors.email ? settings.errorBorderColor : settings.inputBorderColor, }} />
{errors.email && ( <span className="error-message" style={{ color: settings.errorTextColor }}> {errors.email} </span> )} </div>
<button type="submit" disabled={isSubmitting} style={{ fontSize: settings.buttonFontSize, color: isSubmitting ? settings.buttonDisabledTextColor : settings.buttonTextColor, backgroundColor: isSubmitting ? settings.buttonDisabledBackgroundColor : settings.buttonBackgroundColor, }}> {isSubmitting ? 'Processing...' : settings.buttonText || 'Submit'} </button>
{message.text && ( <div className={`message message-${message.type}`} style={{ color: message.type === 'error' ? settings.errorTextColor : settings.successTextColor, }}> {message.text} </div> )} </form> </div> );};
export default YourBlock;Key Patterns:
- Use
useSettingsState()for block configuration - Controlled form inputs with state
- Validation before API calls
- Loading states during submission
- Error handling with user feedback
- Settings applied to inline styles
5. Editor Controls
Section titled “5. Editor Controls”Create controls/index.jsx
Section titled “Create controls/index.jsx”Block settings sidebar in the editor:
import { InspectorControls } from '@wordpress/block-editor';import { PanelBody, TextControl } from '@wordpress/components';import { useSettingsDispatch, useSettingsState } from '@wpengine/ecom-ui';import Color from '../../wp-ui/controls/color';import FontSize from '../../wp-ui/controls/font-size';
export default function YourBlockControls() { const settingsState = useSettingsState(); const settingsDispatch = useSettingsDispatch();
const updateSetting = (key, value) => { settingsDispatch({ type: 'UPDATE_SETTING', payload: { key, value }, }); };
return ( <InspectorControls> <PanelBody title="Content" initialOpen={true}> <TextControl label="Title Text" value={settingsState?.titleText || 'Default Title'} onChange={(value) => updateSetting('titleText', value)} help="Main heading text" />
<TextControl label="Button Text" value={settingsState?.buttonText || 'Submit'} onChange={(value) => updateSetting('buttonText', value)} /> </PanelBody>
<PanelBody title="Title Styling" initialOpen={false}> <FontSize label="Title Font Size" name="titleFontSize" settings={settingsState} dispatch={settingsDispatch} block="yourBlockName" small={18} medium={24} big={32} />
<Color label="Title Color" name="titleColor" settings={settingsState} dispatch={settingsDispatch} block="yourBlockName" /> </PanelBody>
<PanelBody title="Button Styling" initialOpen={false}> <FontSize label="Button Font Size" name="buttonFontSize" settings={settingsState} dispatch={settingsDispatch} block="yourBlockName" small={14} medium={16} big={18} />
<Color label="Button Text Color" name="buttonTextColor" settings={settingsState} dispatch={settingsDispatch} block="yourBlockName" />
<Color label="Button Background Color" name="buttonBackgroundColor" settings={settingsState} dispatch={settingsDispatch} block="yourBlockName" />
<Color label="Button Disabled Text Color" name="buttonDisabledTextColor" settings={settingsState} dispatch={settingsDispatch} block="yourBlockName" />
<Color label="Button Disabled Background Color" name="buttonDisabledBackgroundColor" settings={settingsState} dispatch={settingsDispatch} block="yourBlockName" /> </PanelBody> </InspectorControls> );}Custom Control Components:
Commerce Connect provides reusable controls:
Color- Color picker with alpha supportFontSize- Preset font size selector- Standard WordPress® components from
@wordpress/components
6. Service Layer
Section titled “6. Service Layer”Create services/block-service.js
Section titled “Create services/block-service.js”API integration layer:
export class YourBlockService { constructor() { this.baseUrl = '/wp-json/commerce-connect/v1'; }
async performAction(data) { const response = await fetch(`${this.baseUrl}/your-endpoint`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': window.wpApiSettings?.nonce || '', }, body: JSON.stringify(data), });
if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || `HTTP ${response.status}`); }
return await response.json(); }}
// Singleton instancelet instance = null;
export function getInstance() { if (!instance) { instance = new YourBlockService(); } return instance;}Service Pattern:
- Centralized API calls
- Error handling
- Singleton instance for efficiency
- Nonce authentication for WordPress® REST API
Create services/__tests__/block-service.test.js
Section titled “Create services/__tests__/block-service.test.js”Unit tests for the service:
import { YourBlockService, getInstance } from '../block-service';
global.fetch = jest.fn();
describe('YourBlockService', () => { let service;
beforeEach(() => { jest.clearAllMocks(); service = new YourBlockService();
// Mock successful response fetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ success: true, message: 'Action completed', }), }); });
test('should successfully perform action', async () => { const result = await service.performAction({ email: 'test@example.com', });
expect(result.success).toBe(true); expect(result.message).toBe('Action completed'); expect(fetch).toHaveBeenCalledWith( '/wp-json/commerce-connect/v1/your-endpoint', expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ 'Content-Type': 'application/json', }), }), ); });
test('should handle API errors', async () => { fetch.mockResolvedValue({ ok: false, status: 400, json: async () => ({ message: 'Invalid request', }), });
await expect(service.performAction({ email: 'invalid' })).rejects.toThrow('Invalid request'); });
test('getInstance should return singleton', () => { const instance1 = getInstance(); const instance2 = getInstance(); expect(instance1).toBe(instance2); });});7. Styling
Section titled “7. Styling”Create style.scss
Section titled “Create style.scss”Component styles:
.commerce-connect-your-block { max-width: 500px; margin: 0 auto; padding: 2rem;
form { display: flex; flex-direction: column; gap: 1.5rem; }
.form-field { display: flex; flex-direction: column; gap: 0.5rem;
label { font-weight: 500; }
input { padding: 0.75rem; border: 1px solid #d1d5db; border-radius: 4px; font-size: 1rem;
&:focus { outline: none; border-color: #7a45e5; box-shadow: 0 0 0 3px rgba(122, 69, 229, 0.1); }
&:disabled { background-color: #f3f4f6; cursor: not-allowed; } }
.error-message { font-size: 0.875rem; display: flex; align-items: center; gap: 0.25rem; } }
button[type='submit'] { padding: 0.75rem 1.5rem; border: none; border-radius: 4px; font-weight: 500; cursor: pointer; transition: all 0.2s;
&:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); }
&:disabled { cursor: not-allowed; opacity: 0.6; } }
.message { padding: 1rem; border-radius: 4px; font-size: 0.875rem;
&.message-success { background-color: #d1fae5; border: 1px solid #6ee7b7; }
&.message-error { background-color: #fee2e2; border: 1px solid #fca5a5; } }}
// Responsive design@media (max-width: 480px) { .commerce-connect-your-block { padding: 1rem;
.form-field input { font-size: 16px; // Prevent zoom on iOS } }}
// Accessibility@media (prefers-reduced-motion: reduce) { .commerce-connect-your-block button { transition: none; }}Integration with Commerce Connect
Section titled “Integration with Commerce Connect”Register Block in Editor
Section titled “Register Block in Editor”Update blocks/_content/wrapper.jsx:
import YourBlockControls from '../your-block-name/controls';import YourBlockComponent from '../your-block-name/components/YourBlock';
// In the component return statement:{ blockName === 'yourBlockName' && ( <> <InspectorControlsWithData> <YourBlockControls /> </InspectorControlsWithData> <YourBlockComponent /> </> );}Add Default Settings
Section titled “Add Default Settings”Update __mocks__/@wpengine/ecom-ui.js:
yourBlockName: { titleText: "Default Title", titleFontSize: "24px", titleColor: "#272D30", buttonText: "Submit", buttonFontSize: "16px", buttonTextColor: "#FFFFFF", buttonBackgroundColor: "#7A45E5", buttonDisabledTextColor: "#73858C", buttonDisabledBackgroundColor: "#F4F5F6", labelFontSize: "14px", labelColor: "#1F2426", inputBorderColor: "#D1D5DB", errorBorderColor: "#EF4444", errorTextColor: "#DC2626", successTextColor: "#059669",}Best Practices
Section titled “Best Practices”Performance
Section titled “Performance”- Lazy Load Components: Use React Suspense
- Optimize Re-renders: Use React.memo for expensive components
- Clean Up Effects: Return cleanup functions from useEffect
- Debounce Input: For search/filter inputs
import { useMemo, useCallback } from 'react';
const YourBlock = () => { // Memoize expensive computations const processedData = useMemo(() => { return expensiveOperation(data); }, [data]);
// Memoize callbacks const handleChange = useCallback((value) => { updateData(value); }, []);
return <div>...</div>;};Accessibility
Section titled “Accessibility”- Semantic HTML: Use proper HTML elements
- ARIA Labels: Add descriptive labels for screen readers
- Keyboard Navigation: Support tab, enter, escape keys
- Focus Management: Manage focus after state changes
<button type="submit" disabled={isSubmitting} aria-busy={isSubmitting} aria-label="Submit form"> {isSubmitting ? 'Processing...' : 'Submit'}</button>Security
Section titled “Security”- Validate Inputs: Both client and server side
- Sanitize Data: Before displaying user content
- Use Nonces: For authenticated requests
- Escape Output: Prevent XSS attacks
// Client-side validationconst validateEmail = (email) => { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);};
// Server-side validation in PHP$email = sanitize_email($request->get_param('email'));if (!is_email($email)) { return new WP_Error('invalid_email', 'Invalid email address');}Testing
Section titled “Testing”- Unit Tests: Test services and utilities
- Component Tests: Test React components in isolation
- Integration Tests: Test block registration and rendering
- E2E Tests: Test full user workflows
// Component test exampleimport { render, screen, fireEvent } from '@testing-library/react';import YourBlock from '../components/YourBlock';
jest.mock('@wpengine/ecom-ui', () => ({ useSettingsState: () => ({ titleText: 'Test Title', buttonText: 'Test Button', }),}));
test('renders block with settings', () => { render(<YourBlock />);
expect(screen.getByText('Test Title')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /test button/i })).toBeInTheDocument();});
test('validates form before submission', async () => { render(<YourBlock />);
const submitButton = screen.getByRole('button'); fireEvent.click(submitButton);
expect(screen.getByText('Email is required')).toBeInTheDocument();});Advanced Patterns
Section titled “Advanced Patterns”Multi-State Components
Section titled “Multi-State Components”For blocks with multiple views (login, register, reset password):
const AUTH_STATES = { LOGIN: 'login', REGISTER: 'register', FORGOT_PASSWORD: 'forgot', RESET_PASSWORD: 'reset',};
const AuthBlock = () => { const [authState, setAuthState] = useState(AUTH_STATES.LOGIN);
const switchState = (newState) => { setAuthState(newState); setErrors({}); setMessage({ type: null, text: '' }); };
return ( <div> {authState === AUTH_STATES.LOGIN && <LoginForm />} {authState === AUTH_STATES.REGISTER && <RegisterForm />} {authState === AUTH_STATES.FORGOT_PASSWORD && <ForgotForm />} {authState === AUTH_STATES.RESET_PASSWORD && <ResetForm />} </div> );};URL Parameter Integration
Section titled “URL Parameter Integration”For blocks that respond to URL parameters:
useEffect(() => { const urlParams = new URLSearchParams(window.location.search); const resetToken = urlParams.get('reset-password');
if (resetToken) { setAuthState(AUTH_STATES.RESET_PASSWORD); setResetToken(resetToken); }}, []);Dedicated Button Controls
Section titled “Dedicated Button Controls”For blocks with multiple customizable buttons:
function PrimaryButtonControl({ settings, dispatch }) { return ( <> <TextControl label="Button Text" value={settings.primaryButtonText || 'Submit'} onChange={(value) => dispatch({ type: 'UPDATE_SETTING', payload: { key: 'primaryButtonText', value }, }) } />
<FontSize label="Button Font Size" name="primaryButtonFontSize" settings={settings} dispatch={dispatch} block="yourBlock" small={14} medium={16} big={18} />
<Color label="Button Text Color" name="primaryButtonTextColor" settings={settings} dispatch={dispatch} block="yourBlock" />
<Color label="Button Background Color" name="primaryButtonBackgroundColor" settings={settings} dispatch={dispatch} block="yourBlock" /> </> );}Related Resources
Section titled “Related Resources”- Hooks & Filters Reference - Extend block behavior
- API Reference - REST API integration
- WordPress® Block Editor Handbook - Official documentation