Custom WordPress Theme Development: How We Do It
Page builders are fine for simple sites. But when you need something specific, a custom theme is the way to go. Here's our process.
Page Builders Are Fine — Until They’re Not
Let’s get this out of the way: Elementor, Divi, and Beaver Builder are perfectly good tools for a lot of projects. Brochure sites, small business pages, personal blogs — a page builder will get you 80% of the way there in a fraction of the time.
But we get a lot of clients who’ve hit the wall with page builders. They need a membership portal with custom role-based content. They want a property listing system that integrates with a third-party API. They’re running a publication with 10,000+ posts and the page builder is choking on query performance.
That’s when you need a custom theme.
At ZenCore Digital, we’ve built dozens of custom WordPress themes. Some for content-heavy publishers, some for SaaS companies using WordPress as a marketing site, some for e-commerce brands that outgrew WooCommerce’s default templates. This post walks through our actual process — the tools we use, the decisions we make, and the code patterns we’ve settled on after years of iteration.
When You Actually Need a Custom Theme
Here’s our honest checklist. If you can answer “yes” to two or more of these, a custom theme is probably worth the investment:
- Custom post types with complex relationships. You need properties linked to agents linked to regions, with custom archive pages for each. A page builder can sort of do this, but it’s duct tape.
- Performance is non-negotiable. Page builders add 300-800KB of CSS and JS to every page load. If your Core Web Vitals scores matter (and they should), that overhead is hard to work around.
- You need a specific design system. Your brand has strict typography, spacing, and component rules. Recreating those in a page builder means fighting the builder’s defaults constantly.
- The content model is structured. Recipes, case studies, product comparisons, documentation — anything with repeating data fields is better served by custom fields than by drag-and-drop layouts.
- Long-term maintainability matters. Page builder content is stored as shortcodes or serialized HTML. If you ever want to switch builders or move away from WordPress, extracting that content is a nightmare.
If none of those apply, save yourself the money and use a page builder. Seriously. A well-built Elementor site is better than a poorly-built custom theme every time.
Our Dev Environment Setup
Local Development
We use Local (formerly Local by Flywheel) for local WordPress development. It’s free, it handles SSL and custom domains, and it makes spinning up new sites trivial. Some developers prefer Docker-based setups with wp-env or custom docker-compose configs, and that’s fine too — but Local reduces friction for the whole team.
Our standard local setup:
- PHP 8.2+
- MySQL 8.0
- Nginx (matches most of our production environments)
- SSL enabled (catches mixed content issues early)
Version Control
Every theme gets its own Git repo. We don’t version control all of WordPress — just the theme directory and sometimes a mu-plugins folder for site-specific functionality.
Our .gitignore for the theme is minimal:
node_modules/
dist/
.DS_Store
*.log
We use a main branch for production, develop for staging, and feature branches for individual pieces of work. Nothing revolutionary, but it works.
Starter Theme or From Scratch?
We’ve gone back and forth on this over the years. We tried Underscores (_s), Sage, Bones, and a few others. Eventually we built our own starter that strips everything down to the essentials.
Our starter includes:
- A clean
functions.phpwith organized includes - Basic template files (index, single, page, archive, 404, header, footer)
- A
webpackorviteconfig for asset compilation - CSS reset and base typography
- Nothing else
The key principle: start with less and add what you need. Every starter theme we tried came with opinions we didn’t share — jQuery dependencies, CSS frameworks, widget areas we’d never use. Our starter is deliberately bare.
If you’re just getting started with custom themes, Underscores is still a solid choice. It’s maintained by Automattic and gives you a clean foundation without too many assumptions.
Theme Structure and the Template Hierarchy
The WordPress template hierarchy is the single most important concept to understand when building custom themes. It determines which PHP file WordPress uses to render any given URL.
Here’s the simplified version that covers 90% of what you need:
Single post → single-{post_type}.php → single.php → singular.php → index.php
Page → page-{slug}.php → page-{id}.php → page.php → singular.php → index.php
Archive → archive-{post_type}.php → archive.php → index.php
Category → category-{slug}.php → category.php → archive.php → index.php
Search results → search.php → index.php
404 → 404.php → index.php
Our File Structure
Here’s what a typical theme directory looks like for us:
theme-name/
├── assets/
│ ├── src/
│ │ ├── css/
│ │ ├── js/
│ │ └── images/
│ └── dist/ (compiled, gitignored)
├── inc/
│ ├── setup.php (theme supports, menus, image sizes)
│ ├── enqueue.php (scripts and styles)
│ ├── custom-post-types.php
│ ├── acf-fields.php
│ ├── helpers.php (utility functions)
│ └── blocks/ (custom Gutenberg blocks)
├── template-parts/
│ ├── content/
│ │ ├── content-post.php
│ │ ├── content-page.php
│ │ └── content-{cpt}.php
│ ├── components/
│ │ ├── card.php
│ │ ├── pagination.php
│ │ └── breadcrumbs.php
│ └── layout/
│ ├── header-nav.php
│ └── footer-widgets.php
├── functions.php
├── style.css
├── index.php
├── single.php
├── page.php
├── archive.php
├── 404.php
├── header.php
├── footer.php
├── search.php
└── screenshot.png
functions.php — Keep It Clean
The biggest mistake we see in custom themes is a functions.php file that’s 800 lines long with everything jammed into it. Theme setup, widget registration, shortcodes, AJAX handlers, custom queries — all in one file.
Don’t do that. Use functions.php as a loader:
<?php
/**
* Theme functions and definitions.
*/
// Theme constants
define('THEME_VERSION', '1.0.0');
define('THEME_DIR', get_template_directory());
define('THEME_URI', get_template_directory_uri());
// Core setup
require_once THEME_DIR . '/inc/setup.php';
// Scripts and styles
require_once THEME_DIR . '/inc/enqueue.php';
// Custom post types and taxonomies
require_once THEME_DIR . '/inc/custom-post-types.php';
// ACF field groups and options pages
if (class_exists('ACF')) {
require_once THEME_DIR . '/inc/acf-fields.php';
}
// Helper functions
require_once THEME_DIR . '/inc/helpers.php';
// Custom Gutenberg blocks
require_once THEME_DIR . '/inc/blocks/register-blocks.php';
And here’s what inc/setup.php looks like:
<?php
/**
* Theme setup: supports, menus, image sizes.
*/
add_action('after_setup_theme', function () {
// HTML5 markup
add_theme_support('html5', [
'search-form',
'comment-form',
'comment-list',
'gallery',
'caption',
'style',
'script',
]);
// Document title tag
add_theme_support('title-tag');
// Post thumbnails
add_theme_support('post-thumbnails');
// Custom image sizes
add_image_size('card-thumbnail', 600, 400, true);
add_image_size('hero-banner', 1920, 800, true);
// Navigation menus
register_nav_menus([
'primary' => __('Primary Navigation'),
'footer' => __('Footer Navigation'),
]);
// Disable block editor full-screen mode by default
add_filter('admin_init', function () {
$script = "window.onload=function(){
const isFullScreen = wp.data.select('core/edit-post').isFeatureActive('fullscreenMode');
if (isFullScreen) wp.data.dispatch('core/edit-post').toggleFeature('fullscreenMode');
}";
wp_add_inline_script('wp-blocks', $script);
});
});
Template Parts and Components
We use get_template_part() aggressively. Any piece of UI that appears in more than one template gets extracted into a template part.
Here’s a typical single.php:
<?php get_header(); ?>
<main id="main-content" class="site-main">
<?php while (have_posts()) : the_post(); ?>
<article <?php post_class('single-post'); ?>>
<?php get_template_part('template-parts/content/content', 'single'); ?>
</article>
<?php
// Related posts
get_template_part('template-parts/components/related-posts');
// Comments
if (comments_open() || get_comments_number()) {
comments_template();
}
?>
<?php endwhile; ?>
</main>
<?php get_footer(); ?>
And template-parts/components/card.php — a reusable card component that takes arguments:
<?php
/**
* Card component.
*
* @param array $args {
* @type int $post_id Post ID.
* @type string $size Image size. Default 'card-thumbnail'.
* @type bool $show_meta Whether to show date and category.
* }
*/
$defaults = [
'post_id' => get_the_ID(),
'size' => 'card-thumbnail',
'show_meta' => true,
];
$args = wp_parse_args($args ?? [], $defaults);
$post_id = $args['post_id'];
?>
<div class="card">
<?php if (has_post_thumbnail($post_id)) : ?>
<div class="card__image">
<a href="<?php echo get_permalink($post_id); ?>">
<?php echo get_the_post_thumbnail($post_id, $args['size'], [
'loading' => 'lazy',
'decoding' => 'async',
]); ?>
</a>
</div>
<?php endif; ?>
<div class="card__content">
<?php if ($args['show_meta']) : ?>
<div class="card__meta">
<time datetime="<?php echo get_the_date('c', $post_id); ?>">
<?php echo get_the_date('', $post_id); ?>
</time>
</div>
<?php endif; ?>
<h3 class="card__title">
<a href="<?php echo get_permalink($post_id); ?>">
<?php echo get_the_title($post_id); ?>
</a>
</h3>
<p class="card__excerpt">
<?php echo wp_trim_words(get_the_excerpt($post_id), 20); ?>
</p>
</div>
</div>
You call it like this:
<?php get_template_part('template-parts/components/card', null, [
'post_id' => $post->ID,
'size' => 'card-thumbnail',
'show_meta' => true,
]); ?>
The third argument to get_template_part() (available since WordPress 5.5) passes data into the template part as $args. This is way cleaner than setting global variables or using set_query_var().
ACF and Custom Fields for Content
Advanced Custom Fields (ACF) is our go-to for structured content. The Pro version is worth every dollar — the repeater field, flexible content layouts, and options pages save enormous amounts of development time.
Why ACF Over Native Custom Fields
WordPress has its own custom fields system (post meta), and you can register meta boxes manually. We’ve done that on projects where ACF wasn’t an option. But ACF gives you:
- A visual field editor (faster to iterate on field groups)
- Conditional logic between fields
- The repeater and flexible content fields (you’d need to write a lot of code to replicate these)
- Local JSON sync (field groups stored as JSON files in your theme, version-controllable)
Field Registration in Code
We define field groups in PHP rather than using the GUI exclusively. This makes them version-controllable and deployable:
<?php
/**
* ACF field groups registered in code.
*/
add_action('acf/init', function () {
// Options page for site-wide settings
if (function_exists('acf_add_options_page')) {
acf_add_options_page([
'page_title' => 'Site Settings',
'menu_title' => 'Site Settings',
'menu_slug' => 'site-settings',
'capability' => 'manage_options',
'position' => 2,
'icon_url' => 'dashicons-admin-settings',
]);
}
});
// Enable local JSON saves
add_filter('acf/settings/save_json', function () {
return THEME_DIR . '/acf-json';
});
add_filter('acf/settings/load_json', function ($paths) {
$paths[] = THEME_DIR . '/acf-json';
return $paths;
});
Using Fields in Templates
Once you have your fields set up, using them in templates is straightforward:
<?php
// Single field
$subtitle = get_field('hero_subtitle');
// Group field
$cta = get_field('call_to_action');
if ($cta) {
echo '<a href="' . esc_url($cta['url']) . '" class="btn">';
echo esc_html($cta['text']);
echo '</a>';
}
// Repeater field
if (have_rows('team_members')) :
while (have_rows('team_members')) : the_row();
$name = get_sub_field('name');
$role = get_sub_field('role');
$photo = get_sub_field('photo');
?>
<div class="team-member">
<?php if ($photo) : ?>
<img src="<?php echo esc_url($photo['sizes']['card-thumbnail']); ?>"
alt="<?php echo esc_attr($name); ?>"
loading="lazy"
width="600"
height="400">
<?php endif; ?>
<h3><?php echo esc_html($name); ?></h3>
<p><?php echo esc_html($role); ?></p>
</div>
<?php
endwhile;
endif;
?>
One rule we follow: always escape output. esc_html(), esc_url(), esc_attr(), wp_kses_post(). No exceptions. It takes two extra seconds to type and prevents XSS vulnerabilities.
Performance From the Start
Building performance into a custom theme from day one is far easier than optimizing a slow theme after the fact. Here’s what we do.
Clean Asset Enqueuing
This is inc/enqueue.php:
<?php
/**
* Enqueue scripts and styles.
*/
add_action('wp_enqueue_scripts', function () {
// Main stylesheet
wp_enqueue_style(
'theme-style',
THEME_URI . '/assets/dist/css/main.css',
[],
THEME_VERSION
);
// Main script — loaded in footer, deferred
wp_enqueue_script(
'theme-script',
THEME_URI . '/assets/dist/js/main.js',
[],
THEME_VERSION,
[
'in_footer' => true,
'strategy' => 'defer',
]
);
// Remove block library CSS if not using Gutenberg on the frontend
// wp_dequeue_style('wp-block-library');
// wp_dequeue_style('wp-block-library-theme');
// Remove global styles if not needed
// wp_dequeue_style('global-styles');
});
Notice we’re using the strategy parameter (added in WordPress 6.3) instead of manually adding defer attributes. Use the API the way it was designed.
Remove What You Don’t Need
WordPress loads a bunch of stuff by default that most sites don’t use:
<?php
/**
* Remove unnecessary default scripts and styles.
*/
add_action('init', function () {
// Remove emoji scripts
remove_action('wp_head', 'print_emoji_detection_script', 7);
remove_action('wp_print_styles', 'print_emoji_styles');
remove_action('admin_print_scripts', 'print_emoji_detection_script');
remove_action('admin_print_styles', 'print_emoji_styles');
// Remove oEmbed discovery
remove_action('wp_head', 'wp_oembed_add_discovery_links');
// Remove XML-RPC tag (if you're not using it)
remove_action('wp_head', 'rsd_link');
remove_action('wp_head', 'wlwmanifest_link');
// Remove WordPress version number
remove_action('wp_head', 'wp_generator');
// Remove shortlink
remove_action('wp_head', 'wp_shortlink_wp_head');
});
// Remove jQuery migrate (if you don't need it)
add_action('wp_default_scripts', function ($scripts) {
if (!is_admin() && isset($scripts->registered['jquery'])) {
$script = $scripts->registered['jquery'];
if ($script->deps) {
$script->deps = array_diff($script->deps, ['jquery-migrate']);
}
}
});
Each of these is small on its own. Together, they shave off a few hundred KB and several HTTP requests.
Images
We set loading="lazy" and decoding="async" on every image that’s not above the fold. WordPress does this automatically for content images since 5.5, but for images in template files, you need to do it manually.
We also define only the image sizes we actually use:
// In setup.php
add_image_size('card-thumbnail', 600, 400, true);
add_image_size('hero-banner', 1920, 800, true);
// Remove default sizes you don't need
add_filter('intermediate_image_sizes_advanced', function ($sizes) {
unset($sizes['medium_large']); // 768px - rarely used
unset($sizes['1536x1536']); // 2x medium_large
unset($sizes['2048x2048']); // 2x large
return $sizes;
});
Every image size you register means WordPress generates another file on upload. If you’ve registered 10 sizes and someone uploads a photo, that’s 10 copies. Only register what you actually use.
Preloading Critical Assets
For above-the-fold performance, we preload the main stylesheet and any critical fonts:
add_action('wp_head', function () {
// Preload main stylesheet
echo '<link rel="preload" href="' . THEME_URI . '/assets/dist/css/main.css" as="style">' . "\n";
// Preload critical font files
echo '<link rel="preload" href="' . THEME_URI . '/assets/dist/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>' . "\n";
}, 1);
And we self-host fonts instead of loading them from Google Fonts. One less DNS lookup, better caching control, and GDPR compliance for European visitors.
Gutenberg Blocks vs Classic Editor
This is a question we get on every project. Our answer has changed over the years.
Our Current Stance
We use Gutenberg for content editing and build custom blocks for anything beyond basic text. The block editor has matured a lot since its rough launch, and for most projects, it’s the right choice now.
We still install the Classic Editor on sites where:
- The client is non-technical and has been using WordPress for years (retraining isn’t worth it)
- The content is almost entirely text with occasional images (the block editor adds complexity without benefit)
- The site uses ACF flexible content layouts for page building (the block editor becomes redundant)
Building Custom Blocks
We use ACF Blocks for most custom blocks. They’re faster to build than React-based blocks and they let you write PHP templates, which keeps everything consistent with the rest of the theme.
Here’s how we register a block in inc/blocks/register-blocks.php:
<?php
/**
* Register custom Gutenberg blocks.
*/
add_action('acf/init', function () {
if (!function_exists('acf_register_block_type')) {
return;
}
// Testimonial block
acf_register_block_type([
'name' => 'testimonial',
'title' => __('Testimonial'),
'description' => __('A customer testimonial with quote, name, and photo.'),
'render_template' => THEME_DIR . '/template-parts/blocks/testimonial.php',
'category' => 'formatting',
'icon' => 'format-quote',
'keywords' => ['testimonial', 'quote', 'review'],
'supports' => [
'align' => false,
'anchor' => true,
'jsx' => true,
],
'example' => [
'attributes' => [
'mode' => 'preview',
'data' => [
'quote' => 'This is a preview of the testimonial block.',
'author' => 'Jane Smith',
'role' => 'CEO, Example Corp',
],
],
],
]);
// CTA Banner block
acf_register_block_type([
'name' => 'cta-banner',
'title' => __('CTA Banner'),
'description' => __('A call-to-action banner with heading, text, and button.'),
'render_template' => THEME_DIR . '/template-parts/blocks/cta-banner.php',
'category' => 'layout',
'icon' => 'megaphone',
'keywords' => ['cta', 'banner', 'call to action'],
'supports' => [
'align' => ['wide', 'full'],
'color' => [
'background' => true,
'text' => true,
],
],
]);
});
And the block template at template-parts/blocks/testimonial.php:
<?php
/**
* Testimonial Block Template.
*
* @param array $block The block settings and attributes.
*/
$quote = get_field('quote');
$author = get_field('author');
$role = get_field('role');
$photo = get_field('photo');
$class_name = 'block-testimonial';
if (!empty($block['className'])) {
$class_name .= ' ' . $block['className'];
}
if (!$quote) {
return;
}
?>
<blockquote class="<?php echo esc_attr($class_name); ?>"
<?php if (!empty($block['anchor'])) : ?>
id="<?php echo esc_attr($block['anchor']); ?>"
<?php endif; ?>>
<p class="block-testimonial__quote"><?php echo esc_html($quote); ?></p>
<footer class="block-testimonial__footer">
<?php if ($photo) : ?>
<img class="block-testimonial__photo"
src="<?php echo esc_url($photo['sizes']['thumbnail']); ?>"
alt="<?php echo esc_attr($author); ?>"
width="80"
height="80"
loading="lazy">
<?php endif; ?>
<div class="block-testimonial__attribution">
<?php if ($author) : ?>
<cite class="block-testimonial__author"><?php echo esc_html($author); ?></cite>
<?php endif; ?>
<?php if ($role) : ?>
<span class="block-testimonial__role"><?php echo esc_html($role); ?></span>
<?php endif; ?>
</div>
</footer>
</blockquote>
This approach gives you full control over the markup, uses the same templating patterns as the rest of your theme, and doesn’t require you to learn React just to build a quote block.
Block Editor Styles
We load a separate stylesheet for the editor so content looks close to the frontend while editing:
add_action('after_setup_theme', function () {
add_theme_support('editor-styles');
add_editor_style('assets/dist/css/editor.css');
});
This doesn’t need to be a perfect match — just close enough that the client isn’t surprised when they hit “Publish.”
Testing and Deployment
What We Test
Before any theme goes live, we run through:
- Cross-browser testing. Chrome, Firefox, Safari, Edge. We use BrowserStack for devices we don’t have.
- Responsive testing. Real devices, not just DevTools. The iOS Safari viewport quirks alone are worth testing on a real phone.
- Accessibility. WAVE, axe DevTools, and manual keyboard navigation. Tab order, focus states, color contrast, screen reader announcements. This isn’t optional.
- Performance. PageSpeed Insights, WebPageTest. We aim for 90+ on mobile for every page template.
- Content edge cases. What happens when there’s no featured image? What about a title that’s 200 characters long? An empty archive? A post with no content? We test the sad paths.
Our Deployment Process
We don’t FTP files to the server. That stopped being acceptable a long time ago.
Our typical deployment pipeline:
- Push to
developbranch — triggers a deploy to the staging server via GitHub Actions or a similar CI tool. - Review on staging — the client checks their content, we check functionality.
- Merge to
main— triggers a deploy to production.
The GitHub Actions workflow for a simple theme deployment looks roughly like this:
name: Deploy Theme
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install and build
run: |
npm ci
npm run build
- name: Deploy to server
uses: burnett01/rsync-deployments@6.0.0
with:
switches: -avzr --delete --exclude='node_modules' --exclude='.git' --exclude='src'
path: ./
remote_path: /var/www/html/wp-content/themes/theme-name/
remote_host: ${{ secrets.DEPLOY_HOST }}
remote_user: ${{ secrets.DEPLOY_USER }}
remote_key: ${{ secrets.DEPLOY_KEY }}
For larger projects, we use platform-specific deployment tools — WP Engine has its own Git push workflow, Kinsta has SSH-based deployment, and so on.
Database and Content Migration
The theme is just code. Content lives in the database. For moving content between environments, we use WP Migrate (formerly WP Migrate DB Pro). It handles serialized data in the database correctly — something raw SQL find-and-replace will break.
For ACF fields and other settings that need to sync between environments, local JSON files and the acf/settings/save_json filter handle that cleanly.
Things We’ve Learned the Hard Way
A few lessons from years of building custom themes that don’t fit neatly into the sections above:
Don’t over-abstract early. It’s tempting to build a massive component library before you’ve written a single template. Build the first three pages with some duplication, then extract the common patterns. You’ll make better abstractions with real code in front of you.
Keep the admin experience in mind. A theme isn’t just what the visitor sees — it’s also what the content editor works with every day. Label your fields clearly, add instructions, use conditional logic to hide irrelevant options. A confused editor will find ways to break things.
Document your decisions. Not in a 50-page spec — just a README in the theme root that covers: how to set up the dev environment, how to build assets, what the custom post types are, and any non-obvious architectural decisions. Future-you will be grateful.
Version your theme properly. Bump the THEME_VERSION constant when you deploy. This busts the browser cache for CSS and JS files. Forgetting this leads to “I deployed the fix but the client still sees the old version” conversations.
Use wp_die() gracefully. If a required plugin (like ACF) isn’t active, don’t let the theme crash with a white screen. Show a clear admin notice explaining what’s needed.
add_action('admin_notices', function () {
if (!class_exists('ACF')) {
echo '<div class="notice notice-error"><p>';
echo '<strong>This theme requires Advanced Custom Fields Pro.</strong> ';
echo 'Please install and activate it.';
echo '</p></div>';
}
});
Wrapping Up
Building a custom WordPress theme is more work than using a page builder. That’s the tradeoff: you’re spending more time upfront to get exactly what you need, with better performance, cleaner code, and full control over every piece of the output.
The process doesn’t have to be complicated. Set up a clean dev environment, structure your files logically, use ACF for content modeling, build performance in from the start, and deploy with version control. That’s it. No magic, no secret sauce — just methodical work.
If you’re thinking about a custom WordPress theme for your project, we’d love to talk through whether it’s the right fit. Check out our WordPress development services for more detail on what we offer, or get in touch to start a conversation.
Need help with your WordPress site?
We can help with the stuff covered in this post. Message us and we'll figure it out.