Skip to content

Building Custom Blocks

Learn how to create custom WordPress® blocks that extend Commerce Connect functionality.

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.

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

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 use commerce-connect/ namespace
  • category: Use commerce-connect for automatic grouping
  • attributes.settingsId: Required for Commerce Connect settings integration
  • viewScript: Loaded on frontend for interactivity

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:

  • BlockProvider wraps the editor preview
  • useBlockProps() provides required wrapper attributes
  • Edit function renders controls + preview
  • Save function creates frontend mount point

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.

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

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

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 support
  • FontSize - Preset font size selector
  • Standard WordPress® components from @wordpress/components

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 instance
let 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);
});
});

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;
}
}

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 />
</>
);
}

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",
}
  1. Lazy Load Components: Use React Suspense
  2. Optimize Re-renders: Use React.memo for expensive components
  3. Clean Up Effects: Return cleanup functions from useEffect
  4. 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>;
};
  1. Semantic HTML: Use proper HTML elements
  2. ARIA Labels: Add descriptive labels for screen readers
  3. Keyboard Navigation: Support tab, enter, escape keys
  4. Focus Management: Manage focus after state changes
<button type="submit" disabled={isSubmitting} aria-busy={isSubmitting} aria-label="Submit form">
{isSubmitting ? 'Processing...' : 'Submit'}
</button>
  1. Validate Inputs: Both client and server side
  2. Sanitize Data: Before displaying user content
  3. Use Nonces: For authenticated requests
  4. Escape Output: Prevent XSS attacks
// Client-side validation
const 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');
}
  1. Unit Tests: Test services and utilities
  2. Component Tests: Test React components in isolation
  3. Integration Tests: Test block registration and rendering
  4. E2E Tests: Test full user workflows
// Component test example
import { 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();
});

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>
);
};

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);
}
}, []);

For blocks with multiple customizable buttons:

blocks/_controls/PrimaryButtonControl.jsx
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"
/>
</>
);
}