Most WordPress projects don’t need a custom theme. That’s the honest starting point. Page builders like Elementor and Bricks, and framework themes like Astra, GeneratePress, and Kadence, cover a wide range of business sites — and they cover them well. The ecosystem has matured to a point where a well-configured Astra child theme with a good Figma-to-site workflow gets a competent developer surprisingly far.
But there’s a threshold. Past a certain level of design complexity, performance requirement, or codebase expectations, these tools start working against you. You spend hours fighting the builder’s layout system to achieve something a developer could write in twenty minutes. Your Lighthouse scores plateau at 65 because you can’t control what CSS loads on which page. Your junior developer opens the theme files and finds serialized block JSON and a 4MB node_modules folder inside a theme directory — not a codebase, just a blob.
That threshold is different for every project. This post is about helping you find it for yours — and if you cross it, understanding what a custom WordPress theme build actually involves.
When You Don’t Need a Custom Theme
A lot of developers (myself included, early on) pushed custom builds when they weren’t necessary. It’s more interesting work. But it’s not always the right call for the client.
Framework themes with well-maintained codebases and large user bases get security patches, compatibility updates, and Gutenberg support with every major WordPress release. That’s real value. When you build something custom, that maintenance responsibility shifts entirely to whoever built it — and to whoever the client hires next.
Here’s a rough decision filter:
- Brochure site, 5-10 pages, standard layout: Astra or GeneratePress with a child theme. Done.
- Blog with standard post types and moderate design: Kadence or a block theme starter. Customize via theme.json and a few template edits.
- WooCommerce store with standard product/category pages: Storefront child theme or Astra + WooCommerce integration. Unless the store has significant custom functionality, custom is overkill.
- Client with in-house marketing team who edits pages frequently: A well-configured page builder setup with a locked design library often serves them better than custom templates.
- Budget under $2,000: Custom theme is not viable. A good framework theme setup is the responsible recommendation.
- Timeline under 3 weeks: Framework themes with child theme customization will get you there. Custom builds need proper planning time.
The risk profile is also worth stating directly: a custom theme is a single point of failure tied to one developer’s decisions and code quality. If that developer disappears, finding someone to maintain an undocumented custom theme is harder and more expensive than swapping out an Astra child theme.
When Custom Is Worth It
There are projects where none of the framework theme options get you where you need to go without significant compromise.
The clearest signal is design complexity. When a design calls for custom post type templates with highly specific layouts, non-standard grid systems, or interactive components that don’t map to any pre-built block or widget, you’re going to spend more time wrestling a framework theme than you would building the right thing from scratch. Builders are good at what they’re built for. They’re poor tools for work that falls outside that boundary.
Performance requirements are another signal — specifically Core Web Vitals when they actually matter for SEO or conversion. A page builder site has a ceiling. It ships with CSS for components you don’t use. Scripts load globally when they’re only needed on one template. You can optimize around the edges, but you can’t fix the architecture.
I rebuilt a client’s Divi site as a custom block theme last year. Before: 9-second load time on mobile, 600KB of CSS, Cumulative Layout Shift of 0.31. After: 1.8 seconds, 28KB of CSS, CLS of 0.02. That’s not optimization — that’s the difference between a tool designed for maximum flexibility and a codebase designed for one site.
The third signal is development team expectations. If a site is going to be maintained by a developer — not just a content editor — the codebase needs to be readable and version-controllable. Custom themes with clean template hierarchies, organized SCSS, and documented ACF field groups are maintainable. Builder-serialized JSON templates are not, at least not without significant pain.
Block Themes vs Classic Themes in 2026
Default to block themes for new projects. That’s my current position, and it’s not particularly close.
Block themes give you theme.json for design token management, the Site Editor for template editing, and a template structure built on HTML files with block markup. The editor experience for clients is more consistent. The design system integration is tighter. And the direction WordPress is moving is clear — Full Site Editing isn’t going away.
Classic themes are still valid in specific situations: legacy codebases that aren’t being rebuilt, workflows that depend on the Classic Editor plugin, and cases where you have significant server-side template logic that’s awkward to express in block context. PHP templates in classic themes are more flexible for complex conditional rendering, role-based content, and dynamic output that doesn’t fit a block pattern well.
Hybrid approaches exist — a classic theme that registers ACF blocks and uses Gutenberg for content editing — and they work. But for a new project starting today with no legacy constraints, block theme is the right foundation.
What theme.json Does
theme.json is the design system layer of a block theme. It controls what the editor exposes to content editors, defines design tokens (colors, fonts, spacing, sizes), and generates CSS custom properties that apply to both the editor and the frontend. Done well, it means your client sees only the colors and font sizes that exist in the design — not WordPress’s full default palette.
Here’s a realistic excerpt from a production theme.json:
{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 3,
"settings": {
"color": {
"defaultPalette": false,
"palette": [
{ "slug": "primary", "color": "#1A1A2E", "name": "Primary" },
{ "slug": "accent", "color": "#E94560", "name": "Accent" },
{ "slug": "surface", "color": "#F5F5F7", "name": "Surface" },
{ "slug": "on-surface", "color": "#0F0F0F", "name": "On Surface" }
]
},
"typography": {
"fontFamilies": [
{
"name": "Inter",
"slug": "inter",
"fontFamily": "Inter, sans-serif",
"fontFace": [
{
"fontFamily": "Inter",
"fontWeight": "400 700",
"fontStyle": "normal",
"fontDisplay": "swap",
"src": [ "file:./assets/fonts/Inter-Variable.woff2" ]
}
]
}
],
"fontSizes": [
{ "slug": "sm", "size": "0.875rem", "name": "Small" },
{ "slug": "base", "size": "1rem", "name": "Base" },
{ "slug": "lg", "size": "1.125rem", "name": "Large" },
{ "slug": "xl", "size": "1.5rem", "name": "XL" },
{ "slug": "2xl", "size": "2rem", "name": "2XL" },
{ "slug": "3xl", "size": "3rem", "name": "3XL" }
],
"defaultFontSizes": false
},
"spacing": {
"spacingScale": {
"operator": "*",
"increment": 1.5,
"steps": 7,
"mediumStep": 1.5,
"unit": "rem"
},
"units": [ "px", "rem", "%" ]
},
"layout": {
"contentSize": "720px",
"wideSize": "1200px"
}
},
"styles": {
"typography": {
"fontFamily": "var(--wp--preset--font-family--inter)",
"fontSize": "var(--wp--preset--font-size--base)",
"lineHeight": "1.7"
},
"elements": {
"h1": { "typography": { "fontSize": "var(--wp--preset--font-size--3xl)", "fontWeight": "700", "lineHeight": "1.15" } },
"h2": { "typography": { "fontSize": "var(--wp--preset--font-size--2xl)", "fontWeight": "700", "lineHeight": "1.25" } },
"h3": { "typography": { "fontSize": "var(--wp--preset--font-size--xl)", "fontWeight": "600", "lineHeight": "1.35" } },
"link": {
"color": { "text": "var(--wp--preset--color--accent)" },
":hover": { "color": { "text": "var(--wp--preset--color--primary)" } }
}
}
}
}
What this achieves: defaultPalette: false removes WordPress’s built-in color options from the editor — the client sees exactly four colors. The Inter variable font is self-hosted in assets/fonts/, so there’s no Google Fonts request, no DNS lookup, no render-blocking external resource. defaultFontSizes: false means editors choose from your six defined sizes, not an open field. The spacing scale generates CSS custom properties automatically. Content and wide widths apply globally to the block editor’s alignment system. All heading and link styles defined here flow to both the editor preview and the frontend without writing a single line of additional CSS.
Template Structure
A block theme’s file structure is readable at a glance. Here’s what a typical custom theme looks like for a portfolio/agency site:
theme-name/
├── theme.json
├── functions.php
├── style.css
├── assets/
│ ├── fonts/
│ │ └── Inter-Variable.woff2
│ ├── css/
│ │ └── main.css
│ └── js/
│ └── main.js
├── templates/
│ ├── index.html
│ ├── single.html
│ ├── single-project.html
│ ├── page.html
│ ├── archive.html
│ └── 404.html
├── parts/
│ ├── header.html
│ ├── footer.html
│ └── project-card.html
└── patterns/
└── hero-section.php
Here’s what single-project.html looks like:
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<main class="wp-block-group is-layout-constrained" id="main-content">
<!-- wp:post-featured-image {"aspectRatio":"16/9","align":"wide"} /-->
<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:post-terms {"term":"project_type","style":{"typography":{"fontSize":"var(--wp--preset--font-size--sm)","textTransform":"uppercase","letterSpacing":"0.1em"}}} /-->
<!-- wp:post-title {"level":1,"style":{"spacing":{"margin":{"top":"var:preset|spacing|20"}}}} /-->
<!-- wp:post-content {"layout":{"type":"constrained"}} /-->
</div>
<!-- /wp:group -->
</main>
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
Compare that to what Elementor or Divi stores in post_content — thousands of characters of serialized JSON with widget settings, responsive breakpoint overrides, and style declarations all mixed together. This template is version-controllable, diffable, and readable by any developer who knows WordPress block markup. That matters when the site needs to be handed off or maintained two years from now.
ACF Blocks for Complex Components
Not everything belongs in a static block template. Testimonials, team members, project showcases, pricing tables — these are data-driven components that need a proper field schema and a render template. ACF Pro’s block registration handles this cleanly.
// functions.php
add_action( 'acf/init', function() {
acf_register_block_type( [
'name' => 'testimonial-card',
'title' => 'Testimonial Card',
'description' => 'Single client testimonial with photo, quote, name, and role.',
'render_template' => get_template_directory() . '/blocks/testimonial-card/testimonial-card.php',
'category' => 'theme-blocks',
'icon' => 'format-quote',
'keywords' => [ 'testimonial', 'quote', 'review' ],
'supports' => [
'align' => false,
'mode' => false,
'jsx' => true,
],
'example' => [
'attributes' => [
'mode' => 'preview',
'data' => [
'quote' => 'The attention to detail in the final build matched the design spec exactly. No surprises.',
'author_name' => 'Sarah Okonkwo',
'author_role' => 'Head of Product, Loopline',
'photo' => [ 'url' => get_template_directory_uri() . '/blocks/testimonial-card/preview.jpg' ],
],
],
],
] );
} );
The render template is a plain PHP file:
<?php
// blocks/testimonial-card/testimonial-card.php
$quote = get_field( 'quote' );
$author_name = get_field( 'author_name' );
$author_role = get_field( 'author_role' );
$photo = get_field( 'photo' );
?>
<figure class="testimonial-card" <?php echo get_block_wrapper_attributes(); ?>>
<?php if ( $photo ) : ?>
<img src="<?php echo esc_url( $photo['url'] ); ?>"
alt="<?php echo esc_attr( $photo['alt'] ); ?>"
width="72" height="72"
class="testimonial-card__photo">
<?php endif; ?>
<blockquote class="testimonial-card__quote">
<p><?php echo esc_html( $quote ); ?></p>
<figcaption class="testimonial-card__meta">
<strong><?php echo esc_html( $author_name ); ?></strong>
<?php if ( $author_role ) : ?>
<span><?php echo esc_html( $author_role ); ?></span>
<?php endif; ?>
</figcaption>
</blockquote>
</figure>
The field group is defined either in the ACF UI and exported to PHP, or written directly as a field group registration. Either way, the render template is inspectable, lintable, and checked into version control alongside the rest of the theme. There’s no magic happening inside a plugin’s database tables.
Performance: What Custom Actually Buys You
A poorly built custom theme won’t outperform a well-configured Astra setup. That needs to be said directly. Custom code is not inherently fast. What custom gives you is control over what ships — and that control, used well, produces measurably better results.
Specifically:
- CSS compiled from SCSS with tree-shaking: Only the styles that exist in your design get compiled. No accordion styles on pages with no accordion. No slider CSS on pages with no slider. A typical custom theme ships 15–40KB of CSS total.
- Block-specific styles enqueued only when the block is present: WordPress’s block asset loading API (
wp_enqueue_block_style()) lets you attach a stylesheet to a specific block so it only loads on pages that render that block. - JavaScript split by entry point: A build pipeline with Webpack or Vite lets you split JS into entry points — global scripts, page-specific scripts, block scripts — so mobile users on simple pages don’t download JS for features they’ll never trigger.
- Self-hosted fonts with
font-display: swap: Defined in theme.json, they load from your server, preloaded in the document head, with no external DNS lookup.
The numbers from the Divi rebuild I mentioned earlier: 600KB CSS down to 28KB, load time from 9 seconds to 1.8 seconds on a 4G mobile connection, CLS from 0.31 to 0.02. Those aren’t marginal improvements — they’re the result of removing architecture that was never designed for this specific site.
What It Costs
For a business site with 8–15 page templates, a set of custom ACF blocks, a theme.json design system, and a proper build pipeline, expect $3,000–$8,000 depending on complexity. That range is wide because the variables are significant.
Things that push the cost up:
- Complex custom post types with multiple taxonomies and relationship fields
- WooCommerce with custom product templates or checkout customization
- Multilingual setup with WPML or Polylang
- Animation-heavy design requiring GSAP or custom IntersectionObserver logic
- Membership or gated content requiring role-based template logic
Things that push the cost down:
- Clean, complete Figma handoff with a defined design system (tokens, components, states)
- Minimal custom blocks — mostly native Gutenberg blocks styled through theme.json
- Clear content structure with no ambiguous “we’ll figure it out later” sections
- No WooCommerce or membership requirements
Maintenance is a separate conversation that often gets skipped. Custom themes need a developer for WordPress core compatibility updates, PHP version changes, and Gutenberg API deprecations. Gutenberg moves fast — block APIs that work today have changed in ways that break custom implementations before. Budget a few hours per month retainer if you want the theme supported properly. I typically recommend a quarterly review at minimum, monthly for sites with active development or high traffic.
FSE Transition: Where It’s Still Rough
Full Site Editing is not finished. That’s not a criticism — it’s a practical observation that should factor into how you scope a project.
Some things that are easy in classic PHP templates are awkward in a block context. Conditional header layouts based on page template or user role. Dynamic navigation that changes based on authentication state. Server-side output that depends on query variables or custom rewrite rules. These patterns require workarounds in FSE — either server-rendered ACF blocks, Block Bindings API usage, or hybrid classic/block approaches.
ACF blocks with server-side rendering solve a lot of these cases, but they add complexity. You’re now maintaining both the block registration layer and the render template, and making sure the editor preview (which uses JavaScript) stays in sync with what the PHP template actually outputs. It’s manageable, but it’s not trivial.
If you’re evaluating a developer for a custom theme build, two useful questions to ask: how do they handle server-side rendering in blocks, and how does their theme.json-to-CSS handoff work. The answers will tell you whether they have a real workflow or are figuring it out per project. A developer who can explain their block rendering strategy and how CSS custom properties from theme.json flow to both the editor and frontend stylesheet has done this before. One who gives vague answers about “using the block editor” probably hasn’t.
Working Together
If you’re at the point in your evaluation where a custom theme looks like the right move, I’d be glad to look at what you’re working with. I build custom WordPress themes for clients at my site — block themes and classic, with ACF, with custom build pipelines, and with the documentation to hand off a codebase that another developer can work in. If you have a design file and a scope, the conversation is simple. Reach out here and we can go from there.