Yesterday I was teaching my web dev class how to build a Gutenberg block inside TailPress. I was rusty, resources were thin, and even the big LLMs fumbled. After a few hours of testing, I landed on a clean, native setup, no ACF, fully Tailwind, tiny bundles. Here’s exactly how to replicate it.
What You're Building
You're going to:
- Build Gutenberg blocks in a TailPress theme without ACF or any plugins
- Write blocks in modern JavaScript (React under the hood, but WordPress provides it via externals)
- Auto-compile all blocks from
resources/blocks/*/index.js→blocks/*.js - Auto-register block types in PHP by scanning for
block.jsonfiles - Style everything with Tailwind (no SCSS compilation needed)
I prefer this approach over ACF blocks when:
- The blocks are primarily presentational (Hero sections, callouts, testimonials)
- I want minimal dependencies and faster deploys
- The theme already uses Tailwind extensively
- I want to leverage WordPress's native block.json system
Step 1: Add Babel Config for React
Create a .babelrc file in your theme root:
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
]
}
This tells Babel (via Laravel Mix) to transpile JSX in your block code.
Step 2: Update webpack.mix.js for WordPress Externals and Block Auto-Build
We need to do three things:
- Map WordPress packages to globals (so React doesn't get bundled)
- Ignore the compiled
blocks/directory in watch mode (prevents infinite rebuild loops) - Auto-compile every block from
resources/blocks/*/index.js→blocks/<name>.js
At the top of your webpack.mix.js, add:
let fs = require('fs');
let glob = require('glob');
let path = require('path');
Then update your existing mix.webpackConfig to include externals and ignore the blocks directory:
mix.webpackConfig({
watchOptions: {
ignored: [
path.posix.resolve(__dirname, './node_modules'),
path.posix.resolve(__dirname, './css'),
path.posix.resolve(__dirname, './js'),
path.posix.resolve(__dirname, './blocks') // prevent watch loop
]
},
externals: {
'@wordpress/blocks': 'wp.blocks',
'@wordpress/i18n': 'wp.i18n',
'@wordpress/block-editor': 'wp.blockEditor',
'@wordpress/components': 'wp.components',
'@wordpress/element': 'wp.element',
'@wordpress/data': 'wp.data'
}
});
The externals mapping is crucial: it tells Webpack to replace @wordpress/element imports with wp.element (the global WordPress exposes). This means React never gets bundled and you're using WordPress's copy.
At the end of your webpack.mix.js, add this auto-registration:
// Auto-compile all blocks in resources/blocks/*/index.js to blocks/*.js
glob.sync('resources/blocks/*/index.js').forEach(file => {
const blockName = path.basename(path.dirname(file));
mix.js(file, `blocks/${blockName}.js`);
});
This loops through every resources/blocks/<name>/index.js and compiles it to blocks/<name>.js. Add a new block folder, and it's automatically picked up on the next build.
Step 3: Register All Blocks in PHP (functions.php)
Add this to your theme's functions.php (or an included file):
/**
* Register all blocks from /blocks directory
*/
function tailpress_register_blocks() {
$blocks_dir = get_stylesheet_directory() . '/blocks';
if ( ! is_dir( $blocks_dir ) ) {
return;
}
// Get all .js files in blocks directory
$block_files = glob( $blocks_dir . '/*.js' );
foreach ( $block_files as $block_file ) {
$block_name = basename( $block_file, '.js' );
$block_path = get_stylesheet_directory() . '/resources/blocks/' . $block_name;
// Check if block.json exists
if ( ! file_exists( $block_path . '/block.json' ) ) {
continue;
}
// Register the block script
wp_register_script(
'tailpress-' . $block_name . '-block',
get_stylesheet_directory_uri() . '/blocks/' . $block_name . '.js',
array( 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-block-editor', 'wp-components' ),
filemtime( $block_file )
);
// Register the block type (reads block.json)
register_block_type( $block_path );
}
}
add_action( 'init', 'tailpress_register_blocks' );
How it works:
- Scans
blocks/*.jsfor compiled block scripts - For each one, looks for a matching
resources/blocks/<name>/block.json - Registers the script with WordPress
- Calls
register_block_type()which readsblock.jsonand wires everything up
The script handle (tailpress-<name>-block) must match what you put in block.json's editorScript field.
Step 4: Install Build Dependencies
Run:
npm install @babel/preset-react react react-dom --save-dev --legacy-peer-deps
Note: We're mapping React to wp.element via externals, so React won't be bundled in production. Installing it as a dev dependency keeps tooling happy during development.
Step 5: Set Up Directory Structure
Create the blocks source directory:
mkdir -p resources/blocks
Also make sure your tailwind.config.js includes block sources in the content array so Tailwind doesn't purge your block classes:
module.exports = {
content: [
'./**/*.php',
'./resources/**/*.js',
'./resources/blocks/**/*.{js,jsx}', // add this
],
// ... rest of config
};
Step 6: Scaffold Your First Block
From your theme root:
cd resources/blocks
npx @wordpress/create-block hero --namespace=tailpress --no-plugin
This creates resources/blocks/hero/ with block.json, index.js, edit.js, save.js, and some SCSS files. Since we're using Tailwind, remove the SCSS files:
rm -f hero/editor.scss hero/style.scss
Step 7: Configure block.json
Your resources/blocks/hero/block.json needs to reference the compiled script. Here's a minimal example:
{
"apiVersion": 3,
"name": "tailpress/hero",
"title": "Hero",
"category": "design",
"icon": "cover-image",
"description": "A simple full-width hero with title and subtitle.",
"keywords": ["header", "cover", "banner"],
"supports": {
"align": ["wide", "full"],
"anchor": true
},
"attributes": {
"title": {
"type": "string",
"source": "html",
"selector": "h1"
},
"subtitle": {
"type": "string",
"source": "html",
"selector": "p"
}
},
"editorScript": "tailpress-hero-block"
}
Key points:
namemust match what you pass toregisterBlockType()inindex.jseditorScriptmust match the handle registered in PHP (tailpress-hero-block)- No
styleoreditorStylekeys because we're using Tailwind via tailpress theme's CSS
Step 8: Write the Edit Component (edit.js)
Here's a clean example using Tailwind classes and WordPress's block editor components:
// resources/blocks/hero/edit.js
import { __ } from '@wordpress/i18n';
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function Edit({ attributes, setAttributes }) {
const { title, subtitle } = attributes;
const blockProps = useBlockProps({
className: 'relative w-full rounded-lg bg-gray-900 text-white px-6 py-16 sm:py-24 lg:px-8',
style: { minHeight: '280px' }
});
return (
<section {...blockProps}>
<div className="mx-auto max-w-3xl text-center">
<RichText
tagName="h1"
className="text-3xl font-bold tracking-tight sm:text-5xl"
placeholder={__('Add hero title…', 'tailpress')}
value={title}
onChange={(value) => setAttributes({ title: value })}
allowedFormats={['core/bold', 'core/italic']}
/>
<RichText
tagName="p"
className="mt-4 text-lg text-gray-300"
placeholder={__('Add subtitle…', 'tailpress')}
value={subtitle}
onChange={(value) => setAttributes({ subtitle: value })}
allowedFormats={['core/italic']}
/>
</div>
</section>
);
}
Notes:
useBlockProps()applies necessary block wrapper attributes and classesRichTextcomponents handle the editing experience (similar to the paragraph block)- Tailwind classes work immediately, no additional CSS needed
Step 9: Write the Save Component (save.js)
The save function outputs the front-end markup. Use RichText.Content for persisted content:
// resources/blocks/hero/save.js
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function Save({ attributes }) {
const { title, subtitle } = attributes;
const blockProps = useBlockProps.save({
className: 'relative w-full rounded-lg bg-gray-900 text-white px-6 py-16 sm:py-24 lg:px-8'
});
return (
<section {...blockProps}>
<div className="mx-auto max-w-3xl text-center">
<RichText.Content
tagName="h1"
className="text-3xl font-bold tracking-tight sm:text-5xl"
value={title}
/>
<RichText.Content
tagName="p"
className="mt-4 text-lg text-gray-300"
value={subtitle}
/>
</div>
</section>
);
}
The save function should mirror your edit component's structure, but output static HTML that gets saved to the database.
Step 10: Register the Block (index.js)
Your index.js imports and registers the block:
// resources/blocks/hero/index.js
import { registerBlockType } from '@wordpress/blocks';
import Edit from './edit';
import Save from './save';
registerBlockType('tailpress/hero', {
edit: Edit,
save: Save
});
This gets compiled to blocks/hero.js by our Mix glob pattern.
Step 11: Build and Test
Run:
npm run watch
Then in WordPress:
- Create or edit a page
- Click the block inserter (+)
- Search for "Hero"
- Add your title and subtitle
- Publish
The Tailwind classes you used in edit.js and save.js will render on the front-end because your theme's CSS already includes them.
Creating Additional Blocks
To scaffold another block:
cd resources/blocks
npx @wordpress/create-block callout --namespace=tailpress --no-plugin
rm -f callout/editor.scss callout/style.scss
Then:
- Update
block.jsonwith your attributes - Write
edit.jsandsave.jswith your Tailwind classes - Ensure
editorScriptmatchestailpress-callout-block - Run
npm run watchto compile
The Mix glob will automatically pick it up.
Why This Over ACF?
ACF blocks are great for complex data structures and non-technical editors who need flexible field management. But for presentational blocks in a TailPress theme, the native approach is cleaner:
- No plugin dependency: Blocks are part of your theme, easier to version control and deploy
- Smaller bundles: No React shipped (using WordPress's copy), no ACF JavaScript
- Native Gutenberg experience: Full access to block toolbar, alignment, spacing controls
- Tailwind-first: All styling happens in your theme CSS, no per-block SCSS compilation
- TypeScript-ready: Can add TypeScript later if needed
- Block variations: Can create multiple variations of the same block easily
I still use ACF blocks for complex forms, repeater-heavy layouts, or when clients need extensive field customization. But for Hero sections, testimonials, callouts, and similar components, native blocks are faster to build and maintain.
Troubleshooting
Block doesn't appear in inserter:
- Check that
register_block_type()is firing (verify your theme is active, check for PHP errors) - Confirm
resources/blocks/<name>/block.jsonexists - Ensure
editorScriptinblock.jsonmatches the handle registered in PHP:tailpress-<name>-block - Check browser console for JavaScript errors
Styles missing on front-end:
- Confirm Tailwind is compiling your theme CSS
- Verify
tailwind.config.jsincludesresources/blocks/**/*.{js,jsx}in content - If using dynamic class names, ensure Tailwind doesn't purge them
Infinite rebuild loop:
- Confirm
watchOptions.ignoredincludes./blocks
*"Can't resolve @wordpress/" errors:**
- You forgot the
externalsmapping inwebpack.mix.js
Block compiles but isn't editable:
- Verify
index.jscallsregisterBlockType('tailpress/<name>', { edit, save }) - Check that
edit.jsandsave.jshave proper default exports
Extending This Pattern
Once you have the base setup, you can:
- Add background images using
MediaUploadcomponent from@wordpress/block-editor - Add InspectorControls for side panel settings (overlay opacity, text alignment, etc.)
- Use
supports.spacinginblock.jsonto let users adjust padding/margins - Create dynamic blocks with PHP render callbacks for complex queries
- Add block variations for different styles of the same component
The WordPress Block Editor Handbook has extensive docs on all available components and APIs.
Quick Reference: Copy-Paste Snippets
.babelrc:
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
webpack.mix.js additions:
let fs = require('fs');
let glob = require('glob');
let path = require('path');
mix.webpackConfig({
watchOptions: {
ignored: [
path.posix.resolve(__dirname, './node_modules'),
path.posix.resolve(__dirname, './css'),
path.posix.resolve(__dirname, './js'),
path.posix.resolve(__dirname, './blocks')
]
},
externals: {
'@wordpress/blocks': 'wp.blocks',
'@wordpress/i18n': 'wp.i18n',
'@wordpress/block-editor': 'wp.blockEditor',
'@wordpress/components': 'wp.components',
'@wordpress/element': 'wp.element',
'@wordpress/data': 'wp.data'
}
});
glob.sync('resources/blocks/*/index.js').forEach(file => {
const blockName = path.basename(path.dirname(file));
mix.js(file, `blocks/${blockName}.js`);
});
PHP autoloader (in functions.php):
function tailpress_register_blocks() {
$blocks_dir = get_stylesheet_directory() . '/blocks';
if ( ! is_dir( $blocks_dir ) ) { return; }
foreach ( glob( $blocks_dir . '/*.js' ) as $block_file ) {
$block_name = basename( $block_file, '.js' );
$block_path = get_stylesheet_directory() . '/resources/blocks/' . $block_name;
if ( ! file_exists( $block_path . '/block.json' ) ) { continue; }
wp_register_script(
'tailpress-' . $block_name . '-block',
get_stylesheet_directory_uri() . '/blocks/' . $block_name . '.js',
array( 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-block-editor', 'wp-components' ),
filemtime( $block_file )
);
register_block_type( $block_path );
}
}
add_action( 'init', 'tailpress_register_blocks' );
Scaffold and clean a block:
cd resources/blocks
npx @wordpress/create-block hero --namespace=tailpress --no-plugin
rm -f hero/editor.scss hero/style.scss
That's the complete setup. You now have a clean, maintainable workflow for building Gutenberg blocks in TailPress without ACF, minimal bundle overhead, and blocks that feel native to WordPress. Happy coding!