WordPress theme development has changed significantly over the past few years. With Full Site Editing (FSE), Gutenberg blocks, and theme.json, we can now build entire websites using a block-based architecture instead of traditional PHP templates.
But what happens when a project needs dynamic content, custom fields, or more complex functionality?
This is where Advanced Custom Fields (ACF) still makes sense.
I've recently been experimenting with building a modern WordPress starter theme that combines Full Site Editing, Gutenberg, ACF Pro, and a lightweight PHP architecture without Composer.
Here's how these technologies fit together and why I think this approach works well for custom WordPress development.
What Makes a WordPress Theme an FSE Theme?
One common misconception is that using ACF somehow makes a WordPress theme less modern or prevents it from being a proper FSE theme.
That's not how it works.
Full Site Editing is primarily about how a theme is structured and how WordPress handles its templates.
Traditional WordPress themes rely on PHP files such as:
header.php
footer.php
page.php
single.php
archive.php
Block themes use a different structure:
theme.json
templates/
index.html
single.html
page.html
parts/
header.html
footer.html
patterns/
These HTML templates contain Gutenberg block markup rather than traditional PHP templates.
For example, a simple page template might look like this:
<!-- wp:template-part {"slug":"header","area":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:post-title {"level":1} /-->
<!-- wp:post-content /-->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","area":"footer"} /-->
WordPress handles the rendering of these blocks automatically.
The main advantage is that templates can be edited visually through the Site Editor while still being maintained as files in a Git repository.
Where Does ACF Fit Into FSE?
Gutenberg already provides a large collection of useful blocks.
For example, you can build a typical landing page using:
- Group and Columns blocks for layouts.
- Heading, Paragraph, and Image blocks for content.
- Query Loop for dynamic post listings.
- Patterns for reusable sections.
- Template Parts for headers and footers.
For many websites, that's enough.
However, some projects require more structured content.
Imagine a website with global settings for contact information, social media links, or business details.
You probably don't want editors manually updating the same information across multiple templates.
Instead, you can create an ACF Options Page and store these values in one place.
Then, a dynamic Gutenberg block can retrieve those values and display them wherever needed.
The important distinction is that ACF handles the data, while Gutenberg handles the page structure.
They solve different problems.
Creating Dynamic Blocks Inside an FSE Theme
Let's take a practical example.
Suppose we have an FSE theme with a footer template:
parts/footer.html
We want to display contact information stored in ACF Options.
Since FSE templates are HTML files, we cannot execute PHP directly inside them.
Instead, we create a dynamic block.
Our theme structure might look like this:
my-theme/
├── app/
│ ├── Theme.php
│ ├── Blocks.php
│ └── ACF/
│ └── Options.php
│
├── blocks/
│ └── footer-info/
│ ├── block.json
│ └── render.php
│
├── parts/
│ └── footer.html
│
├── templates/
├── functions.php
└── theme.json
The block can be registered using its block.json metadata.
{
"apiVersion": 3,
"name": "my-theme/footer-info",
"title": "Footer Info",
"category": "theme",
"render": "file:./render.php"
}
Notice that we're using the native WordPress render property.
We don't actually need ACF Blocks for this example because the block retrieves its data from an ACF Options Page rather than exposing editable fields inside the block itself.
In render.php, we can retrieve the data:
<?php
$footer_email = function_exists('get_field')
? get_field('footer_email', 'option')
: '';
if ($footer_email) {
printf(
'<a href="%s">%s</a>',
esc_url('mailto:' . $footer_email),
esc_html($footer_email)
);
}
Finally, we insert the block into our footer template:
<!-- wp:my-theme/footer-info /-->
WordPress renders the block dynamically whenever the footer is displayed.
We now have a fully editable FSE footer containing dynamic data from ACF.
The theme remains a block theme, and we haven't needed to replace the footer with a traditional PHP template.
Keeping the Theme Architecture Clean Without Composer
Another thing I wanted to explore was keeping the starter theme lightweight.
Composer is useful for managing PHP dependencies, but a small WordPress theme doesn't necessarily need it just to autoload a few classes.
PHP already provides spl_autoload_register().
A simple namespace-based autoloader can look like this:
spl_autoload_register(function (string $class): void {
$prefix = 'MyTheme\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relative_class = substr($class, strlen($prefix));
$file = get_theme_file_path(
'app/' . str_replace('\\', '/', $relative_class) . '.php'
);
if (is_readable($file)) {
require_once $file;
}
});
This allows classes to be organized naturally:
MyTheme\Theme → app/Theme.php
MyTheme\Blocks → app/Blocks.php
MyTheme\ACF\Options → app/ACF/Options.php
We can also automatically register all blocks from a dedicated directory:
public static function register(): void
{
$blocks_dir = get_theme_file_path('blocks');
foreach (glob($blocks_dir . '/*/block.json') ?: [] as $block_json) {
register_block_type(dirname($block_json));
}
}
Now adding another block is as simple as creating a new directory containing block.json and its required files.
No additional registration code is needed.
This approach isn't intended to replace Composer in larger applications. It's simply a lightweight solution for themes that don't require external PHP dependencies.
Patterns vs. Dynamic Blocks
One thing worth understanding when working with FSE is the difference between Patterns and dynamic blocks.
Patterns are primarily reusable layouts.
For example, a Hero Pattern might contain a heading, paragraph, button, and image. When inserted into a page, its initial content can be customized.
Dynamic blocks work differently.
Their output can be generated at runtime using PHP or other registered rendering mechanisms.
Here's how I generally approach different components:
| Component | Approach |
|---|---|
| Hero section | Gutenberg Pattern |
| Features section | Gutenberg Pattern |
| Blog listing | Query Loop |
| Global CTA | Synced Pattern |
| Footer contact details | Dynamic Block + ACF |
| Complex custom fields | ACF Block |
| Interactive functionality | Custom Block + JavaScript |
The goal isn't to turn every component into a custom block.
It's to use native Gutenberg functionality whenever possible and introduce custom development only when it provides a clear benefit.
What About Block Bindings?
Another interesting part of modern WordPress development is the Block Bindings API.
It allows supported block attributes to receive values from registered data sources.
For example, instead of creating an entire custom block just to display a footer email address, we can register a custom binding source that retrieves the value from ACF Options.
This can be particularly useful for simple dynamic content.
However, custom dynamic blocks still make sense when we need more control over markup, conditional rendering, or complex functionality.
I see Block Bindings as another tool in the architecture rather than a complete replacement for custom blocks.
My Preferred Workflow for New WordPress Projects
If I were starting a new WordPress website from a Figma design today, my workflow would look something like this:
1. Define the design system. Extract typography, colors, spacing, and layout widths from Figma and configure them in theme.json.
2. Build the theme foundation. Set up the FSE templates, template parts, PHP autoloading, and block registration.
3. Create reusable Patterns. Build common sections using native Gutenberg blocks wherever possible.
4. Add dynamic functionality. Use Query Loop, Block Bindings, ACF, or custom blocks depending on the requirements.
5. Refine the editor experience. Configure block locking, available controls, and reusable components so content editors can work without accidentally breaking layouts.
6. Export and version the theme. Keep templates, patterns, styles, and PHP code in Git. Test the theme on a clean WordPress installation to verify that nothing depends on database-only changes.
This provides a balance between visual editing and traditional development practices.
Final Thoughts
I don't think modern WordPress development needs to be a choice between Gutenberg and ACF.
Both can work together.
Gutenberg and FSE provide the foundation for building flexible, visually editable websites. ACF remains useful for structured data, global settings, and complex content models.
And PHP is still an important part of the architecture, especially when working with dynamic blocks and custom integrations.
For me, the most practical approach is to use native WordPress features first, then introduce ACF and custom PHP where they genuinely simplify development.
The result is a WordPress theme that developers can maintain through code while content editors can manage through Gutenberg.
That's the direction I'm exploring for my future WordPress projects.