A WordPress theme is a collection of files that controls how your site looks and how content is displayed. Building one from scratch teaches you how WordPress actually works under the hood – how it decides which template to load, how it pulls content from the database, and how themes hook into the rest of the system. This guide covers both classic themes (PHP templates) and block themes (the newer approach using
theme.json
and HTML templates), plus when to build from scratch versus starting from an existing theme.
The minimum files a theme needs#
A WordPress theme requires exactly two files to be recognized:
style.css with a specific header comment:
/*
Theme Name: My Custom Theme
Theme URI: https://example.com/my-theme
Author: Your Name
Author URI: https://example.com
Description: A lightweight custom theme built from scratch.
Version: 1.0.0
Requires at least: 6.0
Tested up to: 6.7
Requires PHP: 8.0
License: GNU General Public License v2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Text Domain: my-custom-theme
*/
Only
Theme Name
is technically required, but including the other fields is good practice. WordPress reads this header to display theme information in Appearance > Themes.
index.php – the fallback template:
<?php get_header(); ?>
<main>
<?php if (have_posts()) : ?>
<?php while (have_posts()) : the_post(); ?>
<article>
<h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
<?php the_excerpt(); ?>
</article>
<?php endwhile; ?>
<?php else : ?>
<p>No content found.</p>
<?php endif; ?>
</main>
<?php get_footer(); ?>
Place both files in a new directory inside
wp-content/themes/
(for example,
my-custom-theme/
). WordPress will recognize it immediately and show it under Appearance > Themes. This two-file theme is functional but bare – no header, no footer, no styles beyond what the browser defaults provide. Everything from here is building on this foundation.
The template hierarchy#
The template hierarchy is how WordPress decides which PHP file to use when rendering a page. It follows a specific lookup order, from most specific to most generic, and falls back to
index.php
if nothing more specific exists.
For example, when a visitor loads a single blog post, WordPress looks for templates in this order:
-
single-{post_type}-{slug}.php(e.g.,single-post-my-first-post.php) -
single-{post_type}.php(e.g.,single-post.php) -
single.php -
singular.php -
index.php
WordPress uses the first file it finds. The same pattern applies to every type of page:
| Page type | Lookup order (simplified) |
|---|---|
| Homepage |
front-page.php
>
home.php
>
index.php
|
| Single post |
single-{type}-{slug}.php
>
single-{type}.php
>
single.php
>
singular.php
>
index.php
|
| Page |
page-{slug}.php
>
page-{id}.php
>
page.php
>
singular.php
>
index.php
|
| Category archive |
category-{slug}.php
>
category-{id}.php
>
category.php
>
archive.php
>
index.php
|
| Author archive |
author-{nicename}.php
>
author-{id}.php
>
author.php
>
archive.php
>
index.php
|
| 404 |
404.php
>
index.php
|
| Search results |
search.php
>
index.php
|
This is why
index.php
is the only required template – it is the ultimate fallback. But a real theme typically includes at least
single.php
,
page.php
,
archive.php
,
404.php
,
header.php
,
footer.php
, and
sidebar.php
.
Understanding the hierarchy is important because it means you can create extremely targeted templates without conditional logic. Need a unique layout for your “About” page? Create
page-about.php
. Need a different layout for a custom post type called “portfolio”? Create
single-portfolio.php
. WordPress handles the routing automatically.
Building a classic theme#
A classic theme uses PHP template files with WordPress template tags to output content. Here is a practical file structure:
my-custom-theme/
style.css
functions.php
index.php
header.php
footer.php
sidebar.php
single.php
page.php
archive.php
404.php
search.php
screenshot.png
functions.php
This is where you register features, load styles and scripts, and configure the theme. It runs every time WordPress loads.
<?php
// Theme setup
add_action('after_setup_theme', function() {
// Enable featured images
add_theme_support('post-thumbnails');
// Enable title tag management
add_theme_support('title-tag');
// Enable HTML5 markup
add_theme_support('html5', [
'search-form',
'comment-form',
'comment-list',
'gallery',
'caption',
]);
// Register navigation menus
register_nav_menus([
'primary' => 'Primary menu',
'footer' => 'Footer menu',
]);
});
// Register widget areas
add_action('widgets_init', function() {
register_sidebar([
'name' => 'Sidebar',
'id' => 'sidebar-1',
'before_widget' => '<div class="widget %2$s">',
'after_widget' => '</div>',
'before_title' => '<h3 class="widget-title">',
'after_title' => '</h3>',
]);
});
Registering styles and scripts properly
Never hardcode
<link>
or
<script>
tags in your header or footer templates. Use
wp_enqueue_scripts
so WordPress can manage dependencies, deduplication, and load order:
add_action('wp_enqueue_scripts', function() {
// Main stylesheet
wp_enqueue_style(
'my-theme-style',
get_stylesheet_uri(),
[],
wp_get_theme()->get('Version')
);
// Custom JavaScript
wp_enqueue_script(
'my-theme-script',
get_theme_file_uri('js/main.js'),
[],
wp_get_theme()->get('Version'),
true // load in footer
);
});
Using
wp_enqueue_style
and
wp_enqueue_script
is not optional style – it is how WordPress manages asset loading. Hardcoded tags bypass the dependency system, can cause duplicate loading, and prevent other plugins from properly optimizing your assets. If your theme hardcodes a jQuery script tag while WordPress also enqueues jQuery, the page loads jQuery twice.
header.php and footer.php
<!-- header.php -->
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo('charset'); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
<?php wp_body_open(); ?>
<header class="site-header">
<div class="site-title">
<a href="<?php echo esc_url(home_url('/')); ?>">
<?php bloginfo('name'); ?>
</a>
</div>
<nav>
<?php wp_nav_menu(['theme_location' => 'primary']); ?>
</nav>
</header>
<!-- footer.php -->
<footer class="site-footer">
<nav>
<?php wp_nav_menu(['theme_location' => 'footer']); ?>
</nav>
<p>© <?php echo date('Y'); ?> <?php bloginfo('name'); ?></p>
</footer>
<?php wp_footer(); ?>
</body>
</html>
wp_head()
and
wp_footer()
are critical. They are hooks where WordPress and plugins inject their CSS, JavaScript, meta tags, and other output. Omitting them breaks most plugins and WordPress features like the admin toolbar, SEO plugin meta tags, and analytics scripts.
Building a block theme#
Block themes are the newer approach, introduced with WordPress 5.9. Instead of PHP templates, they use HTML-based templates and a
theme.json
file for configuration. Customization happens through the Full Site Editor rather than code.
Minimum block theme structure
my-block-theme/
style.css
theme.json
templates/
index.html
parts/
header.html
footer.html
Note: a block theme does not need
index.php
. WordPress detects it as a block theme when it finds
templates/index.html
and
theme.json
.
theme.json
This file defines your theme’s design tokens – colors, fonts, spacing, layout widths – and controls which editing options are available in the Site Editor:
{
"$schema": "https://schemas.wp.org/wp/6.7/theme.json",
"version": 3,
"settings": {
"color": {
"palette": [
{ "slug": "primary", "color": "#1a1a2e", "name": "Primary" },
{ "slug": "secondary", "color": "#e94560", "name": "Secondary" },
{ "slug": "light", "color": "#f5f5f5", "name": "Light" },
{ "slug": "dark", "color": "#16213e", "name": "Dark" }
]
},
"typography": {
"fontFamilies": [
{
"fontFamily": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
"slug": "system",
"name": "System"
}
],
"fontSizes": [
{ "slug": "small", "size": "0.875rem", "name": "Small" },
{ "slug": "medium", "size": "1rem", "name": "Medium" },
{ "slug": "large", "size": "1.5rem", "name": "Large" }
]
},
"layout": {
"contentSize": "720px",
"wideSize": "1200px"
},
"spacing": {
"units": ["px", "em", "rem", "%"]
}
},
"styles": {
"color": {
"background": "var(--wp--preset--color--light)",
"text": "var(--wp--preset--color--dark)"
},
"typography": {
"fontFamily": "var(--wp--preset--font-family--system)",
"fontSize": "var(--wp--preset--font-size--medium)",
"lineHeight": "1.6"
}
}
}
WordPress converts
theme.json
values into CSS custom properties (like
--wp--preset--color--primary
), making them available throughout the Site Editor and in your templates.
Block templates
Templates in a block theme use block markup – the same HTML comments that the Gutenberg editor produces:
<!-- templates/index.html -->
<!-- wp:template-part {"slug":"header","area":"header"} /-->
<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:query {"queryId":1,"query":{"perPage":10}} -->
<!-- wp:post-template -->
<!-- wp:post-title {"isLink":true} /-->
<!-- wp:post-excerpt /-->
<!-- /wp:post-template -->
<!-- wp:query-pagination -->
<!-- wp:query-pagination-previous /-->
<!-- wp:query-pagination-next /-->
<!-- /wp:query-pagination -->
<!-- /wp:query -->
</div>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","area":"footer"} /-->
The syntax is verbose, but you do not typically write it by hand. Build the layout in the Site Editor, then export or copy the generated markup into your template files. The HTML files are the source of truth – the Site Editor reads from and writes to them.
Classic vs block: which to choose
Choose a classic theme if you are comfortable with PHP, need precise control over the HTML output, or are building something that does not fit the block editor model (complex web applications, highly interactive pages).
Choose a block theme if you want end users to customize layouts without code, prefer a declarative approach to design tokens, or are building a theme for distribution where non-developers will use it. Block themes are the direction WordPress is heading, and the Full Site Editor tooling improves with every release.
Both approaches are fully supported. Classic themes are not deprecated and will not be. But new features and investment from the WordPress core team are concentrated on the block system.
Custom post types#
WordPress comes with two built-in post types: posts and pages. Custom post types let you create additional content types with their own templates, archives, and admin interfaces.
Register a custom post type in
functions.php
:
add_action('init', function() {
register_post_type('portfolio', [
'labels' => [
'name' => 'Portfolio',
'singular_name' => 'Portfolio item',
'add_new_item' => 'Add new portfolio item',
'edit_item' => 'Edit portfolio item',
],
'public' => true,
'has_archive' => true,
'menu_icon' => 'dashicons-portfolio',
'supports' => ['title', 'editor', 'thumbnail', 'excerpt'],
'rewrite' => ['slug' => 'portfolio'],
'show_in_rest' => true, // required for Gutenberg editor
]);
});
After registering, flush permalinks by visiting Settings > Permalinks and clicking Save (or run
wp rewrite flush
via WP-CLI). WordPress will now:
- Add a “Portfolio” menu item in the admin dashboard
- Create an archive page at
/portfolio/ - Use
single-portfolio.phpfor individual items (falling back through the template hierarchy if that file does not exist) - Use
archive-portfolio.phpfor the archive page
Custom post types are one of the most powerful features for theme developers because they let you model your content structure to match the actual site requirements instead of forcing everything into posts and pages.
Starter themes vs building from scratch#
Building from a completely blank theme teaches you the fundamentals, but for production work, starting from a base theme saves significant time.
Underscores (_s)
Underscores is a starter theme created by Automattic (the company behind WordPress.com). It provides a minimal, well-structured starting point with all the essential template files, basic CSS, and properly set up
functions.php
. It does not include design opinions – it is intentionally bare.
Download it from underscores.me. You enter your theme name and it generates a ready-to-customize theme package. It is a classic theme, so it uses PHP templates.
GeneratePress and similar lightweight themes
GeneratePress, Astra, and Kadence are full themes designed to be lightweight and extensible. They are not starter themes in the traditional sense – they are finished products that you customize through hooks, filters, child themes, or the Customizer. If you need to ship a site quickly and want a solid foundation without building from scratch, these are practical choices. The performance difference between a lightweight theme and a bloated one is measurable in real-world page load times.
Create Block Theme plugin
For block themes, WordPress offers the Create Block Theme plugin. Install it, build your layout in the Site Editor, then export it as a theme. This is the fastest way to create a block theme because you design visually and the plugin generates the
theme.json
, templates, and template parts for you.
When to build from scratch
Build from scratch when you need to deeply understand how WordPress themes work (learning), when the project requirements are unusual enough that no existing theme is a reasonable starting point, or when performance constraints demand absolute control over every byte of output. For most other cases, starting from a base and customizing is the more practical path.
Child themes: the safe way to modify an existing theme#
If you want to customize an existing theme rather than build a completely new one, a child theme is the standard approach. A child theme inherits everything from its parent and lets you override only the specific files you want to change. Parent theme updates apply cleanly without overwriting your customizations.
We covered child theme creation in detail in how to change, install, and customize a WordPress theme – including the required files, what you can override, and why this approach matters for updates. If your goal is modifying an existing theme rather than creating one from nothing, start there.
Common mistakes in custom theme development#
Not escaping output. Every piece of dynamic data rendered in a template must be escaped. Use
esc_html()
for text content,
esc_attr()
for HTML attributes,
esc_url()
for URLs, and
wp_kses_post()
for content that should allow safe HTML. Unescaped output is a cross-site scripting (XSS) vulnerability.
<!-- Wrong -->
<a href="<?php echo $url; ?>"><?php echo $title; ?></a>
<!-- Correct -->
<a href="<?php echo esc_url($url); ?>"><?php echo esc_html($title); ?></a>
Not using translation functions. Even if you are building a theme for a single site in one language, using translation functions (
__()
,
_e()
,
esc_html__()
,
esc_html_e()
) is good practice. It makes the theme translatable later without rewriting all the output strings.
Hardcoding URLs. Never hardcode your site URL in templates. Use
home_url()
,
site_url()
,
get_template_directory_uri()
, and similar functions. Hardcoded URLs break when the site moves to a different domain or switches between HTTP and HTTPS. This is one of the causes behind mixed content errors after migrations.
Not calling
wp_head()
and
wp_footer()
. These hooks are how WordPress and plugins inject scripts, styles, and meta tags. Omitting them breaks the admin bar, SEO plugins, analytics, and most plugin functionality.
Including jQuery manually. WordPress ships with jQuery. If you need it, enqueue it as a dependency rather than including your own copy:
wp_enqueue_script('my-script', get_theme_file_uri('js/main.js'), ['jquery'], '1.0', true);
Not adding
show_in_rest
to custom post types. Without
'show_in_rest' => true
, your custom post type uses the classic editor instead of Gutenberg. This is an easy one to miss.