How I Structure a Scalable WordPress Project in 2026

Quick Summary
- WordPress projects break down less from traffic than from nobody knowing where things live, so give everything an obvious home.
- Keep functions.php to require lines, give each inc file one concern, and map each ACF layout to a same-named template part.
- Put escaping and image logic in helpers, and handle URL changes in code with a rewrite version constant, 301 redirects and 410 responses.
WordPress projects rarely fall over because of traffic. They fall over because their WordPress project structure was an afterthought, and two years in, nobody can say where anything lives.
That’s what I mean by a scalable WordPress project structure: a new section, a new post type or a changed URL has an obvious home, and adding one doesn’t require reading the whole codebase first. Here is the tree of the theme that runs my own site, rebuilt this year, followed by the rules that shaped it.
The whole WordPress project structure on one screen
nmh47-v2/
├── style.css # theme header, nothing else
├── functions.php # constants + require lines
├── header.php, footer.php
├── page.php # a page = its sections (or plain content)
├── single.php, index.php, archive-projects.php, single-projects.php, 404.php
├── inc/
│ ├── setup.php # theme supports, menus, image sizes
│ ├── post-types.php # post types, taxonomies, rewrite version, old-URL redirects
│ ├── acf.php # local JSON paths, options pages, safe getters
│ ├── template-tags.php # small markup and escaping helpers
│ ├── sections.php # the Flexible Content renderer
│ ├── assets.php # enqueues, preloads, per-section scripts
│ ├── seo.php # redirect map and 410 list
│ └── … # one file per concern: analytics, code blocks, blog REST route
├── template-parts/
│ ├── sections/ # one file per ACF layout: hero-home.php, faq.php …
│ ├── cards/ # work-card.php, blog-card.php
│ └── global/ # loader, pager, header meta
├── acf-json/ # field groups, versioned with the templates
└── assets/
├── css/main.css # the design's CSS, untouched
├── css/wp.css # WordPress-only additions
├── fonts/
└── js/ # main.js + page scripts, loaded only where neededNothing in it is clever, and that’s deliberate. Every folder answers one question, and each rule below is one of those questions.
Two annotations deserve a sentence before the rules. main.css is the approved design’s stylesheet, kept as it was; everything WordPress needs on top (the admin bar, editor content, code blocks) lives in wp.css. When something looks wrong, you know which file to open. And acf-json/ sits at the root rather than inside inc/, because it isn’t code. It’s the content model.
functions.php only requires files
My functions.php defines three constants, then requires one file per concern, plus an admin-only file behind is_admin(). It doesn’t register a post type, enqueue a script or add a single hook. The basic technique is in splitting functions.php into separate files.
Every file in inc/ owns one concern, and the file name is the concern. Post types live in post-types.php, enqueues in assets.php, redirects in seo.php. When something breaks, the name of the problem is usually the name of the file.
It sounds too simple to matter. It’s the rule that makes the others possible, and it’s also what makes an AI coding agent useful in a codebase rather than risky: an agent that can find the one file responsible for something edits one file. I go into that side in my AI workflow for WordPress builds.
Every layout maps to one file
Pages are built from ACF Flexible Content, and the mapping is strict: one layout per design section, one template part per layout, same name. The layout featured_works renders template-parts/sections/featured-works.php. The renderer is the only code that knows the naming convention:
function mytheme_render_sections( $post_id = null ) {
$post_id = $post_id ?: get_the_ID();
if ( ! function_exists( 'have_rows' ) || ! have_rows( 'sections', $post_id ) ) {
return;
}
$index = 0;
while ( have_rows( 'sections', $post_id ) ) {
the_row();
$index++;
get_template_part(
'template-parts/sections/' . str_replace( '_', '-', get_row_layout() ),
null,
[
'index' => $index, // for unique ids when a section appears twice
'post_id' => $post_id,
]
);
}
}Two details earn their place. The index gives each section a number for unique IDs and aria-labelledby targets, because the same section can appear twice on one page. And because ACF keeps the current row in its own loop state, the template part can call get_sub_field() without being handed the row.
The field groups behind those layouts live in acf-json/ as ACF Local JSON, next to the templates that read them, so a template and the field it expects are one change in version control. Scripts follow the same map: a section’s JavaScript loads only on pages that contain that section, a pattern I walk through in building a theme without Elementor. Why ACF at all is its own argument, made in why I still use ACF for custom websites.
Templates arrange; helpers format
A template part decides structure: which elements, in what order, with which classes. It shouldn’t decide how to escape a multi-line title, whether an image loads lazily or what to do with an empty link field. That logic goes into small helpers in template-tags.php:
- An image helper that renders lazy-loaded, async-decoded images by default, and switches to eager loading with
fetchpriority="high"when a template marks an image as above the fold. - A link helper that turns ACF link arrays and plain URLs into one shape, and adds
rel="noopener"when a link opens a new tab. - Text helpers that escape output but keep line breaks, so a two-line hero title stays two lines without allowing arbitrary HTML.
The field getters live next door, in acf.php. Templates never call get_field() directly; they call a wrapper that returns a fallback when ACF is inactive or the field is empty, so a deactivated plugin means missing content, not a fatal error. The simplest form of that guard is checking that ACF is active before using get_field().
Escaping happens in one place, so it happens every time. When a loading rule changes, it changes once.
Thin templates also mean fewer variables in a scope you don’t fully own. Top-level templates like single.php run in global scope, and every part loaded through get_template_part() pulls a set of WordPress globals into its scope, $post, $wp_query and $id among them. This theme had template variables named $more and $id. Both are WordPress globals, and the clash caused a fatal error on single posts until they were renamed. Specific names ($project_id, not $id) rule out the whole category.
Post types in a WordPress project: theme or plugin?
The textbook answer is that custom post types belong in a plugin, because content shouldn’t disappear when the theme changes. The textbook is right, and I didn’t follow it here.
My portfolio post type is registered in the theme’s inc/post-types.php. It keeps the internal names it already had, projects and project-category, so the 45 existing projects, their terms and their field data needed no migration when the site moved to the new theme. On a personal site where this theme is the only thing that will ever display a portfolio item, that trade-off is acceptable.
On a client project I’d lean the other way. A small plugin, or a must-use plugin (a file in wp-content/mu-plugins that WordPress always loads and nobody can deactivate from the admin), that registers the post types survives a redesign, and the next developer doesn’t find out the hard way that switching themes makes the portfolio vanish from the admin. My rule of thumb: if the content outlives the design, its registration should too.
URLs are code, so version them
Changing a URL base is the change most likely to hurt a live site, and it usually happens on a settings screen that leaves no record. I treat it as code.
When project pages moved from /projects/ to /project/, everything happened in one file. The rewrite slug changed, and a version constant was bumped, which makes WordPress flush its rewrite rules once on the next request, on every environment, without anyone opening the Permalinks screen (re-saving that screen is the manual fix for a custom post type that returns 404):
const MYTHEME_REWRITE_VERSION = '2'; // bump whenever a rewrite slug changes
add_action( 'init', function () {
if ( get_option( 'mytheme_rewrite_version' ) === MYTHEME_REWRITE_VERSION ) {
return;
}
flush_rewrite_rules( false );
update_option( 'mytheme_rewrite_version', MYTHEME_REWRITE_VERSION );
}, 99 ); // late, so post types are registered first
add_action( 'after_switch_theme', fn() => delete_option( 'mytheme_rewrite_version' ) );Every old URL pattern got a permanent redirect in the same file: the archive, single projects and category pages, with query strings kept. The same routine resets the SEO plugin’s stored portfolio URLs, since it caches permalinks and wouldn’t notice the change by itself.
Removed URLs get the same treatment. A short map in seo.php lists paths that moved elsewhere (the old contact page now 301s to my Upwork profile) and paths that are gone for good: leftover WooCommerce pages return 410 Gone, because the site has no store. A 410 states that the removal is deliberate, where a 404 could just as well be a broken link. Both rules only apply when nothing exists at the URL, so restoring a page makes it work again, and nobody has to remember that a redirect is hiding it.
The ten-second project structure test
The test I use for a WordPress project structure is simple: can someone new find where a thing lives in ten seconds? The hero’s markup, the script it loads, the fields it reads, the redirect for its old URL.
If the answer is a file name they could have guessed, the project will survive growth, handovers and redesigns. If the answer is “search the admin”, it won’t, however clean the code inside each file is.
- One concern per file;
functions.phponly requires. - One layout, one template part, same name.
- Field groups versioned next to the templates that read them.
- Helpers format and escape; templates arrange.
- URL changes live in code, with a version number.
If one installation has to run several sites, see How to Set Up WordPress Multisite.

