Get an ad-free experience when you join the WebWash Premium Community.
Don’t forget to subscribe to our YouTube channel to stay up-to-date.
Enjoy an ad-free experience and access exclusive courses when you join the WebWash Premium Community.
Learn about PremiumAlready a member? Log in
Both WordPress and Drupal, with Canvas, let you build pages from blocks and components instead of using just a text area. But the way they go about it is very different.
The two editors look similar, but they work in opposite ways. The easiest way to see the difference is to build the same thing in both. In the video, we build a hero component twice: first as a custom Gutenberg block, then as a Drupal Single Directory Component (SDC).
First we look at the main difference between the two editors. Then we build the hero as a Gutenberg block. Then we build the same hero as a Drupal SDC.
Gutenberg vs Canvas
The simplest way to describe the difference is that Gutenberg is content-first and Canvas is component-first.
Gutenberg is built for writers. Out of the box it comes with a big library of ready-made blocks like Paragraph, Heading, Image, Cover, and Columns. A writer can open a page and start typing straight away. You only build a custom block when you need something the core blocks don’t give you. When you do, a custom block is a JavaScript and React component with a build step, and the final HTML is saved straight into the post in the database.

Canvas is built for site builders. It doesn’t come with a set of generic content blocks. Instead, the Canvas components you see in the editor are the ones defined in modules or your theme. A Canvas component can be a block, a views block, a single directory component (SDC), or a code component (JavaScript file you build with Canvas). The most common approach is to create an SDC in your theme, define your props and slots, and then build pages from those components.

Building a Hero Block in WordPress Gutenberg
We build the hero inside a custom plugin called ww-blocks. The plugin is a container for blocks. Today it holds one block called hero, but you can drop in more blocks later and they register on their own.
These three files are the most important:
block.jsonis the ID card for the block. It holds the name, the attributes, and where the scripts and styles live.edit.jsis what you see and edit inside the admin editor.save.jsis the HTML that is saved to the post and shown on the front end.
Before you start, make sure you have Node v20 or higher and npm installed.
Step 1:
cd wp-content/plugins
mkdir -p ww-blocks/src/hero
cd ww-blocks
Step 2:
Create ww-blocks.php, this registers every block found in /build.
<?php
/**
* Plugin Name: WW Blocks
* Description: A collection of custom blocks. Currently: Hero.
* Version: 1.0.0
* Text Domain: ww-blocks
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // No direct access.
}
function ww_blocks_register_blocks() {
$build_dir = __DIR__ . '/build';
if ( ! is_dir( $build_dir ) ) {
return;
}
foreach ( glob( $build_dir . '/*', GLOB_ONLYDIR ) as $block_dir ) {
register_block_type( $block_dir );
}
}
add_action( 'init', 'ww_blocks_register_blocks' );
Step 3:
Create package.json, then install it. This pulls in the official @wordpress/scripts toolchain, all set up for you.
{
"name": "ww-blocks",
"version": "1.0.0",
"description": "A collection of custom WordPress blocks.",
"private": true,
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start"
},
"devDependencies": {
"@wordpress/scripts": "^32.0.0"
}
}
npm install
Step 4:
Create src/hero/block.json. This sets the block name, its attributes (the data it stores), and which compiled files to load.
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "ww-blocks/hero",
"title": "Hero (WW)",
"category": "design",
"icon": "cover-image",
"description": "A simple hero with a heading, paragraph, and an image side by side.",
"textdomain": "ww-blocks",
"supports": {
"html": false
},
"attributes": {
"heading": {
"type": "string",
"source": "html",
"selector": "h2"
},
"body": {
"type": "string",
"source": "html",
"selector": "p"
},
"imageUrl": {
"type": "string"
},
"imageAlt": {
"type": "string",
"default": ""
}
},
"editorScript": "file:./index.js",
"style": "file:./style-index.css",
"editorStyle": "file:./index.css"
}
Step 5:
Create src/hero/index.js. This is the JavaScript entry point that ties the metadata, the editor view, and the saved output together.
import { registerBlockType } from '@wordpress/blocks';
import metadata from './block.json';
import Edit from './edit';
import save from './save';
import './style.scss';
registerBlockType( metadata.name, {
edit: Edit,
save,
} );
Step 6:
Create src/hero/edit.js. This is the React component the editor shows. It has a rich-text heading, a rich-text paragraph, and a media uploader for the image.
import { __ } from '@wordpress/i18n';
import {
useBlockProps,
RichText,
MediaUpload,
MediaUploadCheck,
} from '@wordpress/block-editor';
import { Button } from '@wordpress/components';
import './editor.scss';
export default function Edit( { attributes, setAttributes } ) {
const blockProps = useBlockProps( { className: 'ww-hero' } );
const { heading, body, imageUrl, imageAlt } = attributes;
return (
<div { ...blockProps }>
<div className="ww-hero__content">
<RichText
tagName="h2"
className="ww-hero__heading"
value={ heading }
onChange={ ( value ) =>
setAttributes( { heading: value } )
}
placeholder={ __( 'Hero heading…', 'ww-blocks' ) }
/>
<RichText
tagName="p"
className="ww-hero__body"
value={ body }
onChange={ ( value ) => setAttributes( { body: value } ) }
placeholder={ __( 'Hero body text…', 'ww-blocks' ) }
/>
</div>
<div className="ww-hero__media">
<MediaUploadCheck>
<MediaUpload
onSelect={ ( media ) =>
setAttributes( {
imageUrl: media.url,
imageAlt: media.alt,
} )
}
allowedTypes={ [ 'image' ] }
render={ ( { open } ) => (
<>
{ imageUrl && (
<img
className="ww-hero__image"
src={ imageUrl }
alt={ imageAlt }
/>
) }
<Button variant="secondary" onClick={ open }>
{ imageUrl
? __( 'Change image', 'ww-blocks' )
: __( 'Select image', 'ww-blocks' ) }
</Button>
</>
) }
/>
</MediaUploadCheck>
</div>
</div>
);
}
Step 7:
Create src/hero/save.js. This is a static block, so what you return here is the front-end HTML that gets saved into the post.
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save( { attributes } ) {
const blockProps = useBlockProps.save( { className: 'ww-hero' } );
const { heading, body, imageUrl, imageAlt } = attributes;
return (
<div { ...blockProps }>
<div className="ww-hero__content">
<RichText.Content
tagName="h2"
className="ww-hero__heading"
value={ heading }
/>
<RichText.Content
tagName="p"
className="ww-hero__body"
value={ body }
/>
</div>
{ imageUrl && (
<div className="ww-hero__media">
<img
className="ww-hero__image"
src={ imageUrl }
alt={ imageAlt }
/>
</div>
) }
</div>
);
}
The save.js file must always produce the same markup for the same attributes. If you change it later without a deprecated entry, WordPress marks existing content as invalid.
Step 8:
Add an SCSS file for the side-by-side layout. It loads on both the front end and inside the editor. You can also add an editor.scss file for editor-only tweaks.
.wp-block-ww-blocks-hero.ww-hero {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2rem;
padding: 2rem;
.ww-hero__content {
flex: 1 1 320px;
}
.ww-hero__media {
flex: 1 1 320px;
}
.ww-hero__image {
display: block;
max-width: 100%;
height: auto;
border-radius: 8px;
}
}
Step 9:
Compile src/ into build/, then activate the plugin. The PHP loop from Step 2 picks up the block on its own.
npm run build
ddev wp plugin activate ww-blocks
Now add a new page, click the inserter, search for “Hero”, and you’ll find it as “Hero (WW)” under the Design category. While you’re developing, run npm run start instead of npm run build so it rebuilds every time you save.
If you want WordPress to scaffold all the steps above for you, run npx @wordpress/create-block — it creates the same structure in one command.
WordPress 7 adds PHP-only block registration which we didn’t cover in the video. For simple, server-rendered blocks you can now skip the JavaScript and the build step entirely. If you want to learn more, click here.

Building the Same Hero as a Drupal SDC
Now let’s build a hero component in Drupal. A Single Directory Component is just a folder with two files, a YAML schema and a Twig template. There’s no JavaScript and no build step for the component itself.
One thing to note is where the component lives. In the video we add the component to the byte theme, which is the theme that ships with Drupal CMS and Canvas. The same steps work in your own custom theme. Just swap the theme path for your theme folder and create the component inside its components directory.
SDCs live in web/themes/contrib/byte_theme/components/.
Step 1:
Move into the theme folder.
cd web/themes/contrib/byte_theme
If you use nvm, run nvm use to match the Node version pinned in the theme .nvmrc file.
Step 2:
Go ahead and create a folder for the hero component.
mkdir components/ww_hero
Step 3:
Create components/ww_hero/ww_hero.component.yml. The props are the editable fields like heading, content, and image. The slot lets editors drop other components inside this one, in this case buttons below the content.
"$schema": "https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json"
name: WW Hero
group: Hero
description: Simple hero with a heading, supporting text and an image on the right.
props:
type: object
properties:
heading_text:
type: string
title: Heading text
examples:
- "Hero Heading"
content:
type: string
title: Content
contentMediaType: text/html
x-formatting-context: block
examples:
- "<p>Hero content goes here…</p>"
image:
title: Image
"$ref": json-schema-definitions://canvas.module/image
examples:
- src: "https://picsum.photos/seed/hero/1600/900"
alt: "Decorative hero image"
width: 1600
height: 900
slots:
buttons:
title: Buttons
description: Add button components here. They appear below the content.
Notice the image prop uses a $ref that points at a Canvas schema definition. That’s how Canvas knows to give editors a proper image picker for this field.
Step 4:
Create components/ww_hero/ww_hero.twig. The markup is Twig rendered on the server, and the classes like flex, gap-6, and md:flex-row are Tailwind utilities, which is why there’s a CSS build step next.
<div class="ww-hero flex flex-col gap-6 md:flex-row md:items-center md:gap-12">
<div class="flex-1">
{% if heading_text %}
<h2 class="text-3xl font-bold md:text-4xl">{{ heading_text }}</h2>
{% endif %}
{% if content %}
<div class="mt-4 prose">{{ content }}</div>
{% endif %}
{% if buttons %}
<div class="mt-6 flex flex-wrap gap-4">{{ buttons }}</div>
{% endif %}
</div>
{% if image.src %}
<div class="flex-1">
{% include 'canvas:image' ignore missing with {
src: image.src,
alt: image.alt,
width: image.width,
height: image.height,
class: 'w-full h-auto rounded-lg',
loading: 'eager'
} only %}
</div>
{% endif %}
</div>
Step 5:
The component itself doesn’t need a build, but the byte theme uses Tailwind, so you compile the CSS to generate the utility classes used in the template. Install the theme tooling, then build.
npm install
npm run build
While you’re developing, run npm run dev instead so Tailwind watches your files and rebuilds when you save.
Step 6:
Clear the cache so Drupal finds the new component and serves the freshly built CSS.
ddev drush cr
Step 7:
The “WW Hero” component now shows up in the Canvas component list under the Hero group. Add it to a page, fill in the heading, content, and image, and it renders.
That’s the whole component. Two files in a folder, a CSS build because of Tailwind, and a cache clear. No JavaScript, no React, and no per-component build step.

Which One Should You Use?
There’s no single winner, because the two editors are built for different people.
If you want a strong writing experience with a big library of ready-made blocks, Gutenberg is hard to beat. The downside is that custom blocks pull you into JavaScript, React, and a build step.
If you want a typed system of server-rendered components that site builders own and editors put together, Canvas and SDCs are a clean fit. The downside is that you have to define your components up front before editors can build anything.
It’s also worth remembering that you don’t have to pick just one. You can run Gutenberg inside Drupal if you want that writing experience there. But if you’re building in Drupal CMS today, Canvas with SDCs is the way things are heading, and building the same hero both ways is a good way to feel why.




