Introduction
Gutenberg, WordPress's block editor, is built entirely with React. By leveraging Sage's modern build system, you can create powerful, reusable custom blocks that integrate seamlessly with your theme while maintaining clean, maintainable code.
In this tutorial, you'll learn how to set up React block development in Sage, create custom blocks with advanced features, and implement best practices for production-ready blocks.
Prerequisites: This tutorial assumes you have a working Sage 10 installation, basic knowledge of React, and familiarity with Gutenberg's block structure.
What You'll Build
Throughout this guide, we'll create several custom blocks:
Testimonial Block
A dynamic testimonial with image, quote, and author details
Pricing Table
Interactive pricing cards with InnerBlocks
Dynamic Posts
Server-side rendered block with React editor
Setting Up the Development Environment
Before creating blocks, we need to configure Sage's build system to handle Gutenberg block development with React.
Install Required Dependencies
First, install the WordPress scripts and block-related packages:
npm install @wordpress/blocks @wordpress/block-editor @wordpress/components @wordpress/i18n @wordpress/element @wordpress/data --save
Note: These packages provide the core functionality for creating Gutenberg blocks, including React components, editor controls, and internationalization support.
Configure bud.config.js
Update your bud.config.js to handle block compilation. Sage 10
uses Bud.js as its build tool:
// bud.config.js
export default async (app) => {
app
.entry('app', ['@scripts/app', '@styles/app'])
.entry('editor', ['@scripts/editor', '@styles/editor'])
.entry('blocks', ['@scripts/blocks/index'])
.assets(['images'])
.setPublicPath('/app/themes/sage/public/')
.when(app.isProduction, () => app.minify().version());
};
Create the Directory Structure
Organize your blocks in a clean, scalable structure:
resources/
├── scripts/
│ ├── blocks/
│ │ ├── index.js # Block registration entry point
│ │ ├── testimonial/
│ │ │ ├── index.js # Block registration
│ │ │ ├── edit.js # Editor component
│ │ │ ├── save.js # Save component
│ │ │ ├── attributes.js # Block attributes
│ │ │ └── style.scss # Block styles
│ │ ├── pricing-table/
│ │ │ ├── index.js
│ │ │ ├── edit.js
│ │ │ ├── save.js
│ │ │ ├── attributes.js
│ │ │ └── style.scss
│ │ └── dynamic-posts/
│ │ ├── index.js
│ │ ├── edit.js # Server-rendered blocks only need edit
│ │ └── style.scss
│ └── editor.js
├── styles/
│ ├── editor.scss # Editor-only styles
│ └── blocks/
│ ├── _index.scss # Import all block styles
│ ├── _testimonial.scss
│ ├── _pricing-table.scss
│ └── _dynamic-posts.scss
└── views/
└── blocks/
└── dynamic-posts.blade.php # Server-side render template
Enqueue Block Assets
Register and enqueue block scripts and styles in your theme's
setup.php:
<?php
// app/setup.php
namespace App;
use function Roots\asset;
/**
* Register Gutenberg block assets
*/
add_action('init', function () {
// Register block editor script
wp_register_script(
'sage/blocks',
asset('blocks.js')->uri(),
[
'wp-blocks',
'wp-element',
'wp-block-editor',
'wp-components',
'wp-i18n',
'wp-data',
'wp-api-fetch',
],
null,
true
);
// Localize script with REST API data
wp_localize_script('sage/blocks', 'sageBlocks', [
'restUrl' => rest_url(),
'nonce' => wp_create_nonce('wp_rest'),
]);
});
/**
* Enqueue block editor assets
*/
add_action('enqueue_block_editor_assets', function () {
wp_enqueue_script('sage/blocks');
});
Building the Testimonial Block
Let's create a testimonial block with rich editor controls, image upload, and responsive design.
Block Registration Entry Point
Create the main entry point that imports all blocks:
// resources/scripts/blocks/index.js
import './testimonial';
import './pricing-table';
import './dynamic-posts';
Define Block Attributes
Attributes define the data structure for your block:
// resources/scripts/blocks/testimonial/attributes.js
const attributes = {
content: {
type: 'string',
source: 'html',
selector: '.testimonial__content',
},
authorName: {
type: 'string',
source: 'html',
selector: '.testimonial__author-name',
},
authorRole: {
type: 'string',
source: 'html',
selector: '.testimonial__author-role',
},
mediaId: {
type: 'number',
},
mediaUrl: {
type: 'string',
source: 'attribute',
selector: '.testimonial__image img',
attribute: 'src',
},
mediaAlt: {
type: 'string',
source: 'attribute',
selector: '.testimonial__image img',
attribute: 'alt',
},
alignment: {
type: 'string',
default: 'center',
},
backgroundColor: {
type: 'string',
default: '#f8f9fa',
},
textColor: {
type: 'string',
default: '#212529',
},
rating: {
type: 'number',
default: 5,
},
};
export default attributes;
Create the Edit Component
The Edit component renders in the Gutenberg editor with all controls:
// resources/scripts/blocks/testimonial/edit.js
import { __ } from '@wordpress/i18n';
import {
RichText,
MediaUpload,
MediaUploadCheck,
InspectorControls,
BlockControls,
AlignmentToolbar,
useBlockProps,
PanelColorSettings,
} from '@wordpress/block-editor';
import {
Button,
PanelBody,
PanelRow,
Placeholder,
RangeControl,
} from '@wordpress/components';
const Edit = ({ attributes, setAttributes }) => {
const {
content,
authorName,
authorRole,
mediaId,
mediaUrl,
mediaAlt,
alignment,
backgroundColor,
textColor,
rating,
} = attributes;
const blockProps = useBlockProps({
className: `testimonial testimonial--align-${alignment}`,
style: {
backgroundColor,
color: textColor,
},
});
const onSelectMedia = (media) => {
setAttributes({
mediaId: media.id,
mediaUrl: media.url,
mediaAlt: media.alt,
});
};
const onRemoveMedia = () => {
setAttributes({
mediaId: undefined,
mediaUrl: undefined,
mediaAlt: undefined,
});
};
// Generate star rating
const renderStars = () => {
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(
<span
key={i}
className={`testimonial__star ${i <= rating ? 'is-filled' : ''}`}
>
★
</span>
);
}
return stars;
};
return (
<>
{/* Block Controls Toolbar */}
<BlockControls>
<AlignmentToolbar
value={alignment}
onChange={(newAlignment) => setAttributes({ alignment: newAlignment })}
/>
</BlockControls>
{/* Sidebar Inspector Controls */}
<InspectorControls>
<PanelBody title={__('Media Settings', 'sage')} initialOpen={true}>
<PanelRow>
<MediaUploadCheck>
<MediaUpload
onSelect={onSelectMedia}
allowedTypes={['image']}
value={mediaId}
render={({ open }) => (
<Button onClick={open} variant="secondary" className="w-100">
{!mediaId ? __('Select Image', 'sage') : __('Replace Image', 'sage')}
</Button>
)}
/>
</MediaUploadCheck>
</PanelRow>
{mediaId && (
<PanelRow>
<Button onClick={onRemoveMedia} variant="link" isDestructive>
{__('Remove Image', 'sage')}
</Button>
</PanelRow>
)}
</PanelBody>
<PanelBody title={__('Rating', 'sage')} initialOpen={true}>
<RangeControl
label={__('Star Rating', 'sage')}
value={rating}
onChange={(value) => setAttributes({ rating: value })}
min={1}
max={5}
/>
</PanelBody>
<PanelColorSettings
title={__('Color Settings', 'sage')}
colorSettings={[
{
value: backgroundColor,
onChange: (color) => setAttributes({ backgroundColor: color }),
label: __('Background Color', 'sage'),
},
{
value: textColor,
onChange: (color) => setAttributes({ textColor: color }),
label: __('Text Color', 'sage'),
},
]}
/>
</InspectorControls>
{/* Block Content */}
<div {...blockProps}>
<div className="testimonial__inner">
{/* Author Image */}
<div className="testimonial__image">
<MediaUploadCheck>
<MediaUpload
onSelect={onSelectMedia}
allowedTypes={['image']}
value={mediaId}
render={({ open }) => (
<>
{!mediaUrl ? (
<Placeholder
icon="format-image"
label={__('Author Image', 'sage')}
instructions={__('Upload or select an image', 'sage')}
>
<Button variant="primary" onClick={open}>
{__('Upload', 'sage')}
</Button>
</Placeholder>
) : (
<img
src={mediaUrl}
alt={mediaAlt}
onClick={open}
style={{ cursor: 'pointer' }}
/>
)}
</>
)}
/>
</MediaUploadCheck>
</div>
{/* Testimonial Body */}
<div className="testimonial__body">
<div className="testimonial__rating">
{renderStars()}
</div>
<RichText
tagName="blockquote"
className="testimonial__content"
placeholder={__('Write testimonial...', 'sage')}
value={content}
onChange={(value) => setAttributes({ content: value })}
/>
<div className="testimonial__author">
<RichText
tagName="cite"
className="testimonial__author-name"
placeholder={__('Author Name', 'sage')}
value={authorName}
onChange={(value) => setAttributes({ authorName: value })}
/>
<RichText
tagName="span"
className="testimonial__author-role"
placeholder={__('Author Role / Company', 'sage')}
value={authorRole}
onChange={(value) => setAttributes({ authorRole: value })}
/>
</div>
</div>
</div>
</div>
</>
);
};
export default Edit;
Create the Save Component
The Save component defines the frontend HTML output:
// resources/scripts/blocks/testimonial/save.js
import { RichText, useBlockProps } from '@wordpress/block-editor';
const Save = ({ attributes }) => {
const {
content,
authorName,
authorRole,
mediaUrl,
mediaAlt,
alignment,
backgroundColor,
textColor,
rating,
} = attributes;
const blockProps = useBlockProps.save({
className: `testimonial testimonial--align-${alignment}`,
style: {
backgroundColor,
color: textColor,
},
});
// Generate star rating HTML
const renderStars = () => {
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(
<span
key={i}
className={`testimonial__star ${i <= rating ? 'is-filled' : ''}`}
>
★
</span>
);
}
return stars;
};
return (
<div {...blockProps}>
<div className="testimonial__inner">
{mediaUrl && (
<div className="testimonial__image">
<img src={mediaUrl} alt={mediaAlt || ''} loading="lazy" />
</div>
)}
<div className="testimonial__body">
<div className="testimonial__rating" aria-label={`Rating: ${rating} out of 5 stars`}>
{renderStars()}
</div>
<RichText.Content
tagName="blockquote"
className="testimonial__content"
value={content}
/>
<footer className="testimonial__author">
<RichText.Content
tagName="cite"
className="testimonial__author-name"
value={authorName}
/>
<RichText.Content
tagName="span"
className="testimonial__author-role"
value={authorRole}
/>
</footer>
</div>
</div>
</div>
);
};
export default Save;
Register the Block
Bring it all together by registering the block:
// resources/scripts/blocks/testimonial/index.js
import { registerBlockType } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import Edit from './edit';
import Save from './save';
import attributes from './attributes';
registerBlockType('sage/testimonial', {
apiVersion: 2,
title: __('Testimonial', 'sage'),
description: __('Display a customer testimonial with image, rating, and author info.', 'sage'),
category: 'common',
icon: 'format-quote',
keywords: [
__('testimonial', 'sage'),
__('quote', 'sage'),
__('review', 'sage'),
],
supports: {
html: false,
align: ['wide', 'full'],
},
attributes,
edit: Edit,
save: Save,
});
Block Styles (SCSS)
Create responsive styles for the testimonial block:
// resources/styles/blocks/_testimonial.scss
.testimonial {
padding: 2rem;
border-radius: 0.5rem;
margin: 1.5rem 0;
&__inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
max-width: 800px;
margin: 0 auto;
@media (min-width: 768px) {
flex-direction: row;
align-items: flex-start;
}
}
&__image {
flex-shrink: 0;
img {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
border: 4px solid rgba(255, 255, 255, 0.2);
}
.components-placeholder {
width: 100px;
height: 100px;
border-radius: 50%;
}
}
&__body {
flex: 1;
}
&__rating {
margin-bottom: 1rem;
}
&__star {
font-size: 1.25rem;
color: #ddd;
margin-right: 2px;
&.is-filled {
color: #ffc107;
}
}
&__content {
font-size: 1.125rem;
font-style: italic;
line-height: 1.7;
margin: 0 0 1rem;
padding: 0;
border: none;
&::before {
content: '"';
font-size: 2rem;
line-height: 0;
vertical-align: -0.4em;
opacity: 0.5;
}
&::after {
content: '"';
font-size: 2rem;
line-height: 0;
vertical-align: -0.4em;
opacity: 0.5;
}
}
&__author {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
&__author-name {
font-style: normal;
font-weight: 600;
font-size: 1rem;
}
&__author-role {
font-size: 0.875rem;
opacity: 0.7;
}
// Alignment variations
&--align-left {
text-align: left;
}
&--align-center {
text-align: center;
.testimonial__inner {
flex-direction: column;
align-items: center;
}
.testimonial__author {
align-items: center;
}
}
&--align-right {
text-align: right;
.testimonial__inner {
@media (min-width: 768px) {
flex-direction: row-reverse;
}
}
.testimonial__author {
align-items: flex-end;
}
}
}
Building the Pricing Table Block with InnerBlocks
InnerBlocks allow you to create container blocks that accept other blocks as children. This is perfect for creating flexible, nested layouts like pricing tables.
Pricing Table Container Block
Attributes
// resources/scripts/blocks/pricing-table/attributes.js
const attributes = {
columns: {
type: 'number',
default: 3,
},
backgroundColor: {
type: 'string',
default: '#ffffff',
},
};
export default attributes;
Edit Component
// resources/scripts/blocks/pricing-table/edit.js
import { __ } from '@wordpress/i18n';
import {
InnerBlocks,
InspectorControls,
useBlockProps,
PanelColorSettings,
} from '@wordpress/block-editor';
import {
PanelBody,
RangeControl,
} from '@wordpress/components';
const ALLOWED_BLOCKS = ['sage/pricing-card'];
const Edit = ({ attributes, setAttributes }) => {
const { columns, backgroundColor } = attributes;
const blockProps = useBlockProps({
className: 'pricing-table',
style: {
'--pricing-columns': columns,
backgroundColor,
},
});
// Template for default pricing cards
const TEMPLATE = [
['sage/pricing-card', { title: 'Basic', price: '$9', period: '/month' }],
['sage/pricing-card', { title: 'Pro', price: '$29', period: '/month', featured: true }],
['sage/pricing-card', { title: 'Enterprise', price: '$99', period: '/month' }],
];
return (
<>
<InspectorControls>
<PanelBody title={__('Layout Settings', 'sage')}>
<RangeControl
label={__('Columns', 'sage')}
value={columns}
onChange={(value) => setAttributes({ columns: value })}
min={1}
max={4}
/>
</PanelBody>
<PanelColorSettings
title={__('Color Settings', 'sage')}
colorSettings={[
{
value: backgroundColor,
onChange: (color) => setAttributes({ backgroundColor: color }),
label: __('Background Color', 'sage'),
},
]}
/>
</InspectorControls>
<div {...blockProps}>
<InnerBlocks
allowedBlocks={ALLOWED_BLOCKS}
template={TEMPLATE}
orientation="horizontal"
/>
</div>
</>
);
};
export default Edit;
Save Component
// resources/scripts/blocks/pricing-table/save.js
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
const Save = ({ attributes }) => {
const { columns, backgroundColor } = attributes;
const blockProps = useBlockProps.save({
className: 'pricing-table',
style: {
'--pricing-columns': columns,
backgroundColor,
},
});
return (
<div {...blockProps}>
<InnerBlocks.Content />
</div>
);
};
export default Save;
Register Parent Block
// resources/scripts/blocks/pricing-table/index.js
import { registerBlockType } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import Edit from './edit';
import Save from './save';
import attributes from './attributes';
// Import the child block
import './pricing-card';
registerBlockType('sage/pricing-table', {
apiVersion: 2,
title: __('Pricing Table', 'sage'),
description: __('Display a responsive pricing table with multiple plans.', 'sage'),
category: 'common',
icon: 'grid-view',
keywords: [__('pricing', 'sage'), __('table', 'sage'), __('plans', 'sage')],
supports: {
html: false,
align: ['wide', 'full'],
},
attributes,
edit: Edit,
save: Save,
});
Pricing Card Child Block
Attributes
// resources/scripts/blocks/pricing-table/pricing-card/attributes.js
const attributes = {
title: {
type: 'string',
source: 'html',
selector: '.pricing-card__title',
},
price: {
type: 'string',
source: 'html',
selector: '.pricing-card__price',
},
period: {
type: 'string',
source: 'html',
selector: '.pricing-card__period',
},
description: {
type: 'string',
source: 'html',
selector: '.pricing-card__description',
},
buttonText: {
type: 'string',
default: 'Get Started',
},
buttonUrl: {
type: 'string',
default: '#',
},
featured: {
type: 'boolean',
default: false,
},
accentColor: {
type: 'string',
default: '#0d6efd',
},
};
export default attributes;
Edit Component
// resources/scripts/blocks/pricing-table/pricing-card/edit.js
import { __ } from '@wordpress/i18n';
import {
RichText,
InnerBlocks,
InspectorControls,
useBlockProps,
PanelColorSettings,
} from '@wordpress/block-editor';
import {
PanelBody,
TextControl,
ToggleControl,
} from '@wordpress/components';
// Only allow list blocks for features
const ALLOWED_BLOCKS = ['core/list'];
const TEMPLATE = [
['core/list', {
values: '<li>Feature one</li><li>Feature two</li><li>Feature three</li>',
className: 'pricing-card__features'
}],
];
const Edit = ({ attributes, setAttributes }) => {
const {
title,
price,
period,
description,
buttonText,
buttonUrl,
featured,
accentColor,
} = attributes;
const blockProps = useBlockProps({
className: `pricing-card ${featured ? 'pricing-card--featured' : ''}`,
style: {
'--accent-color': accentColor,
},
});
return (
<>
<InspectorControls>
<PanelBody title={__('Card Settings', 'sage')}>
<ToggleControl
label={__('Featured Plan', 'sage')}
checked={featured}
onChange={(value) => setAttributes({ featured: value })}
help={__('Highlight this plan as recommended', 'sage')}
/>
</PanelBody>
<PanelBody title={__('Button Settings', 'sage')}>
<TextControl
label={__('Button Text', 'sage')}
value={buttonText}
onChange={(value) => setAttributes({ buttonText: value })}
/>
<TextControl
label={__('Button URL', 'sage')}
value={buttonUrl}
onChange={(value) => setAttributes({ buttonUrl: value })}
type="url"
/>
</PanelBody>
<PanelColorSettings
title={__('Color Settings', 'sage')}
colorSettings={[
{
value: accentColor,
onChange: (color) => setAttributes({ accentColor: color }),
label: __('Accent Color', 'sage'),
},
]}
/>
</InspectorControls>
<div {...blockProps}>
{featured && (
<div className="pricing-card__badge">
{__('Most Popular', 'sage')}
</div>
)}
<div className="pricing-card__header">
<RichText
tagName="h4"
className="pricing-card__title"
placeholder={__('Plan Name', 'sage')}
value={title}
onChange={(value) => setAttributes({ title: value })}
/>
<div className="pricing-card__pricing">
<RichText
tagName="span"
className="pricing-card__price"
placeholder={__('$99', 'sage')}
value={price}
onChange={(value) => setAttributes({ price: value })}
/>
<RichText
tagName="span"
className="pricing-card__period"
placeholder={__('/month', 'sage')}
value={period}
onChange={(value) => setAttributes({ period: value })}
/>
</div>
<RichText
tagName="p"
className="pricing-card__description"
placeholder={__('Plan description...', 'sage')}
value={description}
onChange={(value) => setAttributes({ description: value })}
/>
</div>
<div className="pricing-card__body">
<InnerBlocks
allowedBlocks={ALLOWED_BLOCKS}
template={TEMPLATE}
templateLock="all"
/>
</div>
<div className="pricing-card__footer">
<span className="pricing-card__button">
{buttonText || __('Get Started', 'sage')}
</span>
</div>
</div>
</>
);
};
export default Edit;
Save Component
// resources/scripts/blocks/pricing-table/pricing-card/save.js
import { RichText, InnerBlocks, useBlockProps } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
const Save = ({ attributes }) => {
const {
title,
price,
period,
description,
buttonText,
buttonUrl,
featured,
accentColor,
} = attributes;
const blockProps = useBlockProps.save({
className: `pricing-card ${featured ? 'pricing-card--featured' : ''}`,
style: {
'--accent-color': accentColor,
},
});
return (
<div {...blockProps}>
{featured && (
<div className="pricing-card__badge">
{__('Most Popular', 'sage')}
</div>
)}
<div className="pricing-card__header">
<RichText.Content
tagName="h4"
className="pricing-card__title"
value={title}
/>
<div className="pricing-card__pricing">
<RichText.Content
tagName="span"
className="pricing-card__price"
value={price}
/>
<RichText.Content
tagName="span"
className="pricing-card__period"
value={period}
/>
</div>
<RichText.Content
tagName="p"
className="pricing-card__description"
value={description}
/>
</div>
<div className="pricing-card__body">
<InnerBlocks.Content />
</div>
<div className="pricing-card__footer">
<a href={buttonUrl} className="pricing-card__button">
{buttonText || __('Get Started', 'sage')}
</a>
</div>
</div>
);
};
export default Save;
Register Child Block
// resources/scripts/blocks/pricing-table/pricing-card/index.js
import { registerBlockType } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import Edit from './edit';
import Save from './save';
import attributes from './attributes';
registerBlockType('sage/pricing-card', {
apiVersion: 2,
title: __('Pricing Card', 'sage'),
description: __('A single pricing plan card.', 'sage'),
category: 'common',
icon: 'money-alt',
parent: ['sage/pricing-table'], // Only allow inside pricing table
supports: {
html: false,
reusable: false,
},
attributes,
edit: Edit,
save: Save,
});
Pricing Table Styles
// resources/styles/blocks/_pricing-table.scss
.pricing-table {
display: grid;
grid-template-columns: repeat(var(--pricing-columns, 3), 1fr);
gap: 1.5rem;
padding: 2rem;
@media (max-width: 991px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 575px) {
grid-template-columns: 1fr;
}
}
.pricing-card {
--accent-color: #0d6efd;
position: relative;
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 0.75rem;
padding: 2rem;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
&--featured {
border-color: var(--accent-color);
border-width: 2px;
transform: scale(1.05);
z-index: 1;
&:hover {
transform: scale(1.05) translateY(-4px);
}
}
&__badge {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: var(--accent-color);
color: #fff;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.35rem 1rem;
border-radius: 2rem;
white-space: nowrap;
}
&__header {
text-align: center;
padding-bottom: 1.5rem;
border-bottom: 1px solid #dee2e6;
margin-bottom: 1.5rem;
}
&__title {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 1rem;
color: #212529;
}
&__pricing {
display: flex;
align-items: baseline;
justify-content: center;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
&__price {
font-size: 3rem;
font-weight: 700;
line-height: 1;
color: var(--accent-color);
}
&__period {
font-size: 1rem;
color: #6c757d;
}
&__description {
font-size: 0.875rem;
color: #6c757d;
margin: 0;
}
&__body {
flex: 1;
margin-bottom: 1.5rem;
ul {
list-style: none;
padding: 0;
margin: 0;
li {
position: relative;
padding: 0.5rem 0 0.5rem 1.75rem;
border-bottom: 1px solid #f1f3f5;
&::before {
content: '✓';
position: absolute;
left: 0;
color: var(--accent-color);
font-weight: 600;
}
&:last-child {
border-bottom: none;
}
}
}
}
&__footer {
margin-top: auto;
}
&__button {
display: block;
width: 100%;
padding: 0.875rem 1.5rem;
font-size: 1rem;
font-weight: 600;
text-align: center;
text-decoration: none;
color: #fff;
background: var(--accent-color);
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
&:hover {
filter: brightness(1.1);
transform: translateY(-2px);
}
}
}
Building a Dynamic Server-Side Rendered Block
Server-side rendered blocks are ideal for dynamic content that needs PHP processing, like displaying recent posts. The block is edited in React but rendered by PHP on the frontend.
Register the Block in PHP
<?php
// app/Blocks/DynamicPosts.php
namespace App\Blocks;
class DynamicPosts
{
public function __construct()
{
add_action('init', [$this, 'register']);
}
public function register()
{
register_block_type('sage/dynamic-posts', [
'api_version' => 2,
'editor_script' => 'sage/blocks',
'render_callback' => [$this, 'render'],
'attributes' => [
'postType' => [
'type' => 'string',
'default' => 'post',
],
'postsPerPage' => [
'type' => 'number',
'default' => 3,
],
'orderBy' => [
'type' => 'string',
'default' => 'date',
],
'order' => [
'type' => 'string',
'default' => 'DESC',
],
'showFeaturedImage' => [
'type' => 'boolean',
'default' => true,
],
'showExcerpt' => [
'type' => 'boolean',
'default' => true,
],
'showDate' => [
'type' => 'boolean',
'default' => true,
],
'columns' => [
'type' => 'number',
'default' => 3,
],
'category' => [
'type' => 'number',
'default' => 0,
],
],
]);
}
public function render($attributes)
{
$args = [
'post_type' => $attributes['postType'],
'posts_per_page' => $attributes['postsPerPage'],
'orderby' => $attributes['orderBy'],
'order' => $attributes['order'],
'post_status' => 'publish',
];
if (!empty($attributes['category'])) {
$args['cat'] = $attributes['category'];
}
$query = new \WP_Query($args);
if (!$query->have_posts()) {
return '<p>' . __('No posts found.', 'sage') . '</p>';
}
return view('blocks.dynamic-posts', [
'posts' => $query->posts,
'attributes' => $attributes,
])->render();
}
}
Initialize the Block Class
<?php
// app/setup.php
// Add this to your setup.php
new \App\Blocks\DynamicPosts();
Create the Blade Template
<!-- resources/views/blocks/dynamic-posts.blade.php -->
@php
$columns = $attributes['columns'] ?? 3;
$showFeaturedImage = $attributes['showFeaturedImage'] ?? true;
$showExcerpt = $attributes['showExcerpt'] ?? true;
$showDate = $attributes['showDate'] ?? true;
@endphp
<div class="dynamic-posts" style="--columns: {{ $columns }}">
<div class="dynamic-posts__grid">
@foreach ($posts as $post)
<article class="dynamic-posts__item">
@if ($showFeaturedImage && has_post_thumbnail($post->ID))
<div class="dynamic-posts__image">
<a href="{{ get_permalink($post->ID) }}">
{!! get_the_post_thumbnail($post->ID, 'medium_large', ['loading' => 'lazy']) !!}
</a>
</div>
@endif
<div class="dynamic-posts__content">
@if ($showDate)
<time class="dynamic-posts__date" datetime="{{ get_the_date('c', $post->ID) }}">
{{ get_the_date('', $post->ID) }}
</time>
@endif
<h4 class="dynamic-posts__title">
<a href="{{ get_permalink($post->ID) }}">
{{ get_the_title($post->ID) }}
</a>
</h4>
@if ($showExcerpt)
<div class="dynamic-posts__excerpt">
{{ wp_trim_words(get_the_excerpt($post->ID), 20) }}
</div>
@endif
<a href="{{ get_permalink($post->ID) }}" class="dynamic-posts__link">
{{ __('Read More', 'sage') }} →
</a>
</div>
</article>
@endforeach
</div>
</div>
JavaScript Edit Component
// resources/scripts/blocks/dynamic-posts/edit.js
import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import {
PanelBody,
SelectControl,
RangeControl,
ToggleControl,
Spinner,
} from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import ServerSideRender from '@wordpress/server-side-render';
const Edit = ({ attributes, setAttributes }) => {
const {
postType,
postsPerPage,
orderBy,
order,
showFeaturedImage,
showExcerpt,
showDate,
columns,
category,
} = attributes;
const blockProps = useBlockProps();
// Fetch post types
const postTypes = useSelect((select) => {
const { getPostTypes } = select('core');
const types = getPostTypes({ per_page: -1 }) || [];
return types
.filter((type) => type.viewable && type.rest_base)
.map((type) => ({
label: type.labels.singular_name,
value: type.slug,
}));
}, []);
// Fetch categories
const categories = useSelect((select) => {
const { getEntityRecords } = select('core');
const cats = getEntityRecords('taxonomy', 'category', { per_page: -1 }) || [];
return [
{ label: __('All Categories', 'sage'), value: 0 },
...cats.map((cat) => ({
label: cat.name,
value: cat.id,
})),
];
}, []);
return (
<>
<InspectorControls>
<PanelBody title={__('Query Settings', 'sage')} initialOpen={true}>
{postTypes.length > 0 && (
<SelectControl
label={__('Post Type', 'sage')}
value={postType}
options={postTypes}
onChange={(value) => setAttributes({ postType: value })}
/>
)}
<RangeControl
label={__('Number of Posts', 'sage')}
value={postsPerPage}
onChange={(value) => setAttributes({ postsPerPage: value })}
min={1}
max={12}
/>
<SelectControl
label={__('Order By', 'sage')}
value={orderBy}
options={[
{ label: __('Date', 'sage'), value: 'date' },
{ label: __('Title', 'sage'), value: 'title' },
{ label: __('Random', 'sage'), value: 'rand' },
{ label: __('Menu Order', 'sage'), value: 'menu_order' },
]}
onChange={(value) => setAttributes({ orderBy: value })}
/>
<SelectControl
label={__('Order', 'sage')}
value={order}
options={[
{ label: __('Descending', 'sage'), value: 'DESC' },
{ label: __('Ascending', 'sage'), value: 'ASC' },
]}
onChange={(value) => setAttributes({ order: value })}
/>
{postType === 'post' && categories.length > 0 && (
<SelectControl
label={__('Category', 'sage')}
value={category}
options={categories}
onChange={(value) => setAttributes({ category: parseInt(value) })}
/>
)}
</PanelBody>
<PanelBody title={__('Display Settings', 'sage')}>
<RangeControl
label={__('Columns', 'sage')}
value={columns}
onChange={(value) => setAttributes({ columns: value })}
min={1}
max={4}
/>
<ToggleControl
label={__('Show Featured Image', 'sage')}
checked={showFeaturedImage}
onChange={(value) => setAttributes({ showFeaturedImage: value })}
/>
<ToggleControl
label={__('Show Excerpt', 'sage')}
checked={showExcerpt}
onChange={(value) => setAttributes({ showExcerpt: value })}
/>
<ToggleControl
label={__('Show Date', 'sage')}
checked={showDate}
onChange={(value) => setAttributes({ showDate: value })}
/>
</PanelBody>
</InspectorControls>
<div {...blockProps}>
<ServerSideRender
block="sage/dynamic-posts"
attributes={attributes}
LoadingResponsePlaceholder={() => (
<div className="dynamic-posts__loading">
<Spinner />
<p>{__('Loading posts...', 'sage')}</p>
</div>
)}
ErrorResponsePlaceholder={() => (
<div className="dynamic-posts__error">
<p>{__('Error loading posts. Please check your settings.', 'sage')}</p>
</div>
)}
/>
</div>
</>
);
};
export default Edit;
Register the Block in JavaScript
// resources/scripts/blocks/dynamic-posts/index.js
import { registerBlockType } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import Edit from './edit';
registerBlockType('sage/dynamic-posts', {
apiVersion: 2,
title: __('Dynamic Posts', 'sage'),
description: __('Display posts dynamically with customizable query options.', 'sage'),
category: 'common',
icon: 'admin-post',
keywords: [__('posts', 'sage'), __('query', 'sage'), __('dynamic', 'sage')],
supports: {
html: false,
align: ['wide', 'full'],
},
edit: Edit,
// No save function - rendered server-side
save: () => null,
});
Dynamic Posts Styles
// resources/styles/blocks/_dynamic-posts.scss
.dynamic-posts {
--columns: 3;
&__grid {
display: grid;
grid-template-columns: repeat(var(--columns), 1fr);
gap: 2rem;
@media (max-width: 991px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 575px) {
grid-template-columns: 1fr;
}
}
&__item {
background: #fff;
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
}
&__image {
aspect-ratio: 16 / 10;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
a:hover img {
transform: scale(1.05);
}
}
&__content {
padding: 1.5rem;
}
&__date {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6c757d;
margin-bottom: 0.5rem;
}
&__title {
font-size: 1.125rem;
font-weight: 600;
line-height: 1.4;
margin: 0 0 0.75rem;
a {
color: #212529;
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: #0d6efd;
}
}
}
&__excerpt {
font-size: 0.9375rem;
color: #6c757d;
line-height: 1.6;
margin-bottom: 1rem;
}
&__link {
display: inline-flex;
align-items: center;
font-size: 0.875rem;
font-weight: 600;
color: #0d6efd;
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: #0a58ca;
}
}
// Editor states
&__loading,
&__error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
background: #f8f9fa;
border-radius: 0.5rem;
text-align: center;
p {
margin: 1rem 0 0;
color: #6c757d;
}
}
&__error {
background: #fff5f5;
p {
color: #dc3545;
}
}
}
Best Practices and Tips
Performance
-
Use
useBlockPropsfor proper wrapper attributes -
Implement lazy loading for images
-
Minimize editor re-renders with
useMemo -
Code-split large blocks with dynamic imports
-
Use
ServerSideRendersparingly in editor
Accessibility
-
Add proper ARIA labels to interactive elements
-
Ensure keyboard navigation works correctly
-
Use semantic HTML in save components
-
Provide alt text for images
-
Test with screen readers
Code Organization
-
Keep edit and save components separate
-
Extract complex logic into custom hooks
-
Use attribute files for large schemas
-
Group related blocks in subdirectories
-
Document block attributes and props
Testing
-
Test block registration and rendering
-
Verify save/load round-trip consistency
-
Test with different themes and plugins
-
Check deprecation migrations
-
Validate HTML output
Block Deprecation Example
When updating block markup, use deprecations to maintain backward compatibility:
// resources/scripts/blocks/testimonial/deprecations.js
const v1 = {
attributes: {
// Old attributes schema
quote: {
type: 'string',
source: 'html',
selector: '.testimonial-quote', // Old class name
},
// ...other old attributes
},
migrate(attributes) {
return {
...attributes,
content: attributes.quote, // Rename attribute
};
},
save({ attributes }) {
// Old save function
return (
<div className="testimonial-old">
<blockquote className="testimonial-quote">
{attributes.quote}
</blockquote>
</div>
);
},
};
export default [v1];
// Add to block registration
import deprecated from './deprecations';
registerBlockType('sage/testimonial', {
// ...other settings
deprecated,
});
Conclusion
You've learned how to build production-ready Gutenberg blocks using React within Sage's modern build system.
Key takeaways from this tutorial:
Static Blocks: Use React components for both editor (edit) and frontend (save) rendering, with attributes stored in the database.
InnerBlocks: Create flexible container blocks that accept nested content, perfect for layouts and repeating patterns.
Dynamic Blocks: Combine React editor interfaces with PHP/Blade rendering for dynamic content that requires server-side processing.
Styling: Maintain separate editor and frontend styles, leveraging Sage's SCSS compilation.

