@wordpress/create-block gave us a working plugin in Part 2. Now we look at every piece — file structure, the PHP entry, asset enqueueing, dynamic render — so you can build a plugin from scratch when you need to.
Plugin file structure (recommended)
The plugin header
my-first-block.php must start with a WordPress plugin header. Without this, WP doesn’t know it’s a plugin:
<?php
/**
* Plugin Name: My First Block
* Description: A callout block for posts and pages.
* Version: 1.0.0
* Author: Mahesh Waghmare
* Author URI: https://maheshwaghmare.com
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: my-first-block
* Requires at least: 6.4
* Requires PHP: 7.4
*
* @package MyFirstBlock
*/
if (!defined('ABSPATH')) {
exit;
}
// Register the block from block.json
add_action('init', function() {
register_block_type(__DIR__ . '/build');
});
The single register_block_type() call points at the build/ directory (where block.json is copied during the webpack build). WordPress reads block.json and wires everything — JS, CSS, attributes, supports — automatically.
Asset enqueueing — handled by block.json
You don’t manually enqueue scripts. Listing them in block.json is enough:
{
"name": "my-first-block/callout",
"editorScript": "file:./index.js",
"viewScript": "file:./view.js",
"style": "file:./style-index.css",
"editorStyle": "file:./index.css"
}
| Field | Loaded |
|---|---|
editorScript | Editor only (wp-admin post-edit screens) |
viewScript | Front-end only (when block is on the page) |
style | Both editor and front-end |
editorStyle | Editor only |
WordPress conditionally enqueues each based on context — no front-end loads index.js (the editor bundle).
Dynamic blocks — render server-side
A static block stores its HTML in post_content and serves it verbatim. A dynamic block stores only the block comment + attributes; PHP renders the HTML on each request.
Use dynamic when the output depends on database state (latest posts, current user, dates).
In block.json:
"render": "file:./render.php"
In src/render.php (NOT build/render.php — webpack copies it):
<?php
// $attributes is auto-populated from the block's attributes
// $content is the InnerBlocks content (if any)
// $block is the parsed block object
$type = $attributes['calloutType'] ?? 'info';
$title = $attributes['title'] ?? '';
?>
<div class="my-callout my-callout-<?php echo esc_attr($type); ?>">
<h4 class="my-callout-title"><?php echo wp_kses_post($title); ?></h4>
<div class="my-callout-body"><?php echo $content; ?></div>
</div>
In src/save.js, return null to opt into dynamic rendering:
export default function Save() {
return null;
}
Building for distribution
npm run build produces the build/ directory. That + your PHP files are what you ship. Common .gitignore:
node_modules/
build/
*.zip
For wp.org submission, you typically build then zip the plugin folder (excluding node_modules and src/). A .wp-distignore file controls what gets included if you use WP’s wp-scripts plugin-zip command.
Verification
After packaging:
- Install fresh on a clean WordPress
- Activate without errors
- Block appears in inserter
- Insert + save a post — block survives a refresh
- View the front-end — block renders correctly
What’s next
Part 11 covers testing — @wordpress/scripts test, React DevTools, block validation, common pitfalls and how to spot them early.
Get the next one in your inbox. Practical tips, no fluff.
More tutorials
View all-
Block Patterns
Register reusable block compositions — landing-page hero, feature grid, FAQ section — that users insert as a complete unit.
25 min
-
Block Attributes and Serialization
Master the attribute system — types, sources, defaults, and how block data round-trips between the editor, the database, and the front-end.
30 min
-
Building Your First Custom Block
Replace the scaffolded placeholder with a real custom Callout block — editable title, body, and a type selector — using registerBlockType + edit/save.
45 min