• Home
  • Blog
  • Building Advanced Blocks: Sage + React + Gutenberg
Building Advanced Blocks: Sage + React + Gutenberg

Building Advanced Blocks: Sage + React + Gutenberg

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 useBlockProps for proper wrapper attributes

  • Implement lazy loading for images

  • Minimize editor re-renders with useMemo

  • Code-split large blocks with dynamic imports

  • Use ServerSideRender sparingly 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.

Resources

Block Editor Handbook

Sage Documentation

Gutenberg GitHub

@wordpress Packages

Share:
img

José Paulino

Front-end Developer based in Miami.