Rizwan Rizwan
HOME SERVICES CASE STUDIES BLOG ABOUT TOOLS $100 FREE Consultation Call →
WORDPRESS

How to Build Custom Gutenberg Blocks in TailPress without ACF

Learn how to build native Gutenberg blocks in TailPress themes using WordPress's block.json, React, and TailwindCSS. No ACF Pro required! This setup keeps bundles small, deploys cleanly, and works seamlessly with your existing TailPress workflow.

Rizwan Rizwan November 1, 2025 15 min read
How to Build Custom Gutenberg Blocks in TailPress without ACF

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.jsblocks/*.js
  • Auto-register block types in PHP by scanning for block.json files
  • 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:

  1. Map WordPress packages to globals (so React doesn't get bundled)
  2. Ignore the compiled blocks/ directory in watch mode (prevents infinite rebuild loops)
  3. Auto-compile every block from resources/blocks/*/index.jsblocks/<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/*.js for 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 reads block.json and 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:

  • name must match what you pass to registerBlockType() in index.js
  • editorScript must match the handle registered in PHP (tailpress-hero-block)
  • No style or editorStyle keys 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 classes
  • RichText components 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.json with your attributes
  • Write edit.js and save.js with your Tailwind classes
  • Ensure editorScript matches tailpress-callout-block
  • Run npm run watch to 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.json exists
  • Ensure editorScript in block.json matches 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.js includes resources/blocks/**/*.{js,jsx} in content
  • If using dynamic class names, ensure Tailwind doesn't purge them

Infinite rebuild loop:

  • Confirm watchOptions.ignored includes ./blocks

*"Can't resolve @wordpress/" errors:**

  • You forgot the externals mapping in webpack.mix.js

Block compiles but isn't editable:

  • Verify index.js calls registerBlockType('tailpress/<name>', { edit, save })
  • Check that edit.js and save.js have proper default exports

Extending This Pattern

Once you have the base setup, you can:

  • Add background images using MediaUpload component from @wordpress/block-editor
  • Add InspectorControls for side panel settings (overlay opacity, text alignment, etc.)
  • Use supports.spacing in block.json to 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!

Rizwan

About Rizwan

Full-stack developer and technical leader with 13+ years building scalable web applications. I help agencies and startups ship faster through strategic guidance and hands-on development.

MORE INSIGHTS

Keep reading for more development wisdom.

DevOps

Redis Object Cache on OpenLiteSpeed: Halve WooCommerce TTFB

Learn how Redis object cache on OpenLiteSpeed can cut WooCommerce Time-to-First-Byte by up to 50%. Follow this step-by-step guide with real benchmarks.

Rizwan Rizwan May 27, 2025 5 min read
WordPress

How to Increase the Memory Limit on WPEngine

When managing a WordPress site on WPEngine, you might find the default memory limits insufficient for your needs. Often, this can be attributed to a memory

Rizwan Rizwan May 6, 2025 3 min read

HOW I CAN HELP YOU

I work with founders, agencies, and developers who need that extra push to get projects live. Whether it's fixing a stubborn bug, steering your tech strategy, or building full apps with my team. You don't have to do it alone

GET UNSTUCK

60-minute call to debug your specific problem. Stop spinning your wheels.

$249
BOOK NOW →

FRACTIONAL CTO

Ongoing strategic guidance to prevent disasters like this.

$2k-7k/mo
LEARN MORE →

CUSTOM DEV

Full project rebuild with our expert team. Done right the first time.

Custom
GET QUOTE →