Build a WordPress Theme from Scratch: 15 Steps With Code
To build a WordPress theme from scratch, create a folder in wp-content/themes/ with a style.css header and an index.php, then split your HTML template into header.php and footer.php, load its CSS and JavaScript through functions.php, and add templates for the homepage, blog, single posts, pages and comments. This guide does all of that in 15 steps, with the code for each file.
This is the written companion to our WordPress theme development from scratch video, which converts a free HTML template into a working theme on camera. The code below has been tidied up from the video so it follows current WordPress practice: assets are enqueued rather than hard coded, output is escaped, and the slider content type lives in a must use plugin rather than the theme.
Classic theme or block theme?
This guide builds a classic theme: PHP template files, with menus and widgets managed in the admin. WordPress also supports block themes, which use HTML templates made of blocks and a theme.json file, and let the site owner change layouts in the Site Editor.
- Choose a classic theme when you are converting an existing HTML design, when you want layouts fixed in code so they cannot be broken from the admin, or when you are maintaining an existing classic site.
- Choose a block theme when the owner wants to edit headers, footers and page layouts themselves, or when you are starting a new site and want to work with where WordPress is heading. The Theme Handbook covers both.
Learning the classic way first is still worthwhile: the template hierarchy, the loop and template tags all carry over.
What you need
- A local WordPress install. Any local setup works; ours is in the Windows, WSL, Docker and PHP setup guide.
- A code editor.
- Basic HTML, CSS and PHP.
- A free HTML template to convert. The video uses Mediplus Lite, a free medical template, but any template with separate CSS and JavaScript files will do.
A theme is a folder of files that WordPress looks for by name. If a specific template does not exist, WordPress falls back through the template hierarchy until it reaches index.php, which is why style.css and index.php are the only two files a classic theme must have.
Step 1: Create the theme folder and style.css
Create a folder in wp-content/themes/. Use lowercase letters and hyphens, for example my-custom-theme. Inside it, create style.css with this header comment. WordPress reads the header to list the theme; without it the theme will not appear.
/*
Theme Name: My Custom Theme
Author: Your Name
Description: A custom classic theme built from an HTML template.
Version: 1.0.0
Requires at least: 6.4
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
*/
Optionally add a screenshot.png of 1200 by 900 pixels to the folder. WordPress shows it on the Themes screen.
Step 2: Add index.php and activate the theme
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo( 'charset' ); ?>">
<?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
<h1><?php bloginfo( 'name' ); ?></h1>
<?php
if ( have_posts() ) :
while ( have_posts() ) :
the_post();
the_title( '<h2>', '</h2>' );
the_content();
endwhile;
endif;
?>
<?php wp_footer(); ?>
</body>
</html>
Go to Appearance > Themes, find My Custom Theme and click Activate. The site now shows your posts in plain HTML. It is not pretty, but it is a working theme.
Keep wp_head() and wp_footer() in every layout. WordPress and plugins use them to print styles, scripts and the admin bar; leave them out and things break in ways that are hard to trace.
Step 3: Set up the homepage and blog page
- Create two pages under Pages > Add New: one called Home and one called Blog.
- Go to Settings > Reading. Under “Your homepage displays”, choose a static page, set Homepage to Home and Posts page to Blog.
- On a site that is reachable from the internet, tick “Discourage search engines from indexing this site” while you build. Untick it when you go live.
- Click Save Changes.
WordPress will now use front-page.php for the homepage (once you create it in step 10) and home.php, or index.php if there is no home.php, for the blog page.
Step 4: Copy the template’s files into the theme
Copy the template’s css, js, fonts and img folders into your theme folder. If the template has its own style.css in the root, rename it (for example to css/template.css) so it does not overwrite your theme’s style.css and its header. Keep the template’s index.html open; you will copy markup out of it in the next steps.
Step 5: Split the HTML into header.php and footer.php
Everything in index.html from the doctype down to the end of the site header goes into header.php. Remove the template’s <link> tags and its <title>; step 6 and step 7 let WordPress print those.
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo( 'charset' ); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
<?php wp_body_open(); ?>
<header class="site-header">
<a class="site-logo" href="<?php echo esc_url( home_url( '/' ) ); ?>">
<?php bloginfo( 'name' ); ?>
</a>
<!-- The rest of the template's header markup goes here -->
</header>
Everything from the site footer to the closing </html> goes into footer.php. Remove the template’s <script> tags; step 7 loads them properly.
<footer class="site-footer">
<!-- The template's footer markup goes here -->
<p>© <?php echo esc_html( gmdate( 'Y' ) ); ?> <?php bloginfo( 'name' ); ?></p>
</footer>
<?php wp_footer(); ?>
</body>
</html>
Now update index.php to use them:
<?php get_header(); ?>
<main class="site-main">
<?php
if ( have_posts() ) :
while ( have_posts() ) :
the_post();
the_title( '<h2>', '</h2>' );
the_content();
endwhile;
endif;
?>
</main>
<?php get_footer(); ?>
Change the header once and every page updates. Image paths inside the template markup need the theme folder in front of them, for example <?php echo esc_url( get_template_directory_uri() ); ?>/img/logo.png. Never type the full /wp-content/themes/... path, because it breaks if the folder is renamed or the site moves.
Step 6: Create functions.php and declare theme support
Create functions.php in the theme folder. This turns on the features a modern theme should have and registers the menu locations.
<?php
/
* Theme setup and assets.
*/
function mytheme_setup() {
// Let WordPress print the <title> tag.
add_theme_support( 'title-tag' );
// Featured images on posts and pages.
add_theme_support( 'post-thumbnails' );
// Modern markup for core output such as the search and comment forms.
add_theme_support( 'html5', array( 'search-form', 'comment-form', 'comment-list', 'gallery', 'caption', 'style', 'script' ) );
// Menu locations you can assign in Appearance > Menus.
register_nav_menus(
array(
'primary' => ( 'Primary Menu', 'my-custom-theme' ),
'footer' => ( 'Footer Menu', 'my-custom-theme' ),
)
);
}
add_action( 'after_setup_theme', 'mytheme_setup' );
Because the theme declares title-tag, WordPress prints the page title for you. That is why the <title> tag was removed from the header. Leave out the closing ?> at the end of functions.php; a stray space or line after it causes “headers already sent” warnings.
Step 7: Enqueue the CSS and JavaScript
Add this to functions.php. Change the file names to match the files your template actually uses, and list them in the same order the template loaded them.
function mytheme_assets() {
$uri = get_template_directory_uri();
$version = wp_get_theme()->get( 'Version' );
// Styles, in the order the template loaded them.
wp_enqueue_style( 'bootstrap', $uri . '/css/bootstrap.min.css', array(), $version );
wp_enqueue_style( 'mytheme-template', $uri . '/css/template.css', array( 'bootstrap' ), $version );
wp_enqueue_style( 'mytheme-style', get_stylesheet_uri(), array( 'mytheme-template' ), $version );
// Scripts, in the footer. Use the jQuery bundled with WordPress.
wp_enqueue_script( 'bootstrap', $uri . '/js/bootstrap.min.js', array( 'jquery' ), $version, true );
wp_enqueue_script( 'mytheme-main', $uri . '/js/main.js', array( 'jquery', 'bootstrap' ), $version, true );
// Threaded comment replies.
if ( is_singular() && comments_open() && get_option( 'thread_comments' ) ) {
wp_enqueue_script( 'comment-reply' );
}
}
add_action( 'wp_enqueue_scripts', 'mytheme_assets' );
WordPress now prints the stylesheets in wp_head() and the scripts before wp_footer(). Delete the template’s own copy of jQuery and use the one that ships with WordPress, listed as a dependency as above; loading two copies causes errors. The theme version number is added to each file address, so changing Version in style.css makes browsers fetch fresh copies.
Step 8: Replace the static menu with a WordPress menu
In header.php, replace the template’s hard coded list of links with:
<nav class="main-nav" aria-label="<?php esc_attr_e( 'Main menu', 'my-custom-theme' ); ?>">
<?php
wp_nav_menu(
array(
'theme_location' => 'primary',
'menu_class' => 'main-menu',
'container' => false,
'fallback_cb' => false,
)
);
?>
</nav>
Go to Appearance > Menus, create a menu, add your pages, tick Primary Menu under display location and save. Use the menu_class value that matches the template’s CSS so the menu keeps its styling. If you remove 'fallback_cb' => false, WordPress lists all your pages when no menu is assigned.
Step 9: Register a Slide content type in a must use plugin
The template’s homepage has a slider. Rather than typing the slides into the template, store them as their own content type so they can be edited in the admin. Register it in a must use plugin, not in the theme: content should not disappear when someone changes theme.
Create wp-content/mu-plugins/homepage-slides.php (create the mu-plugins folder if it does not exist):
<?php
/
* Plugin Name: Homepage Slides
* Description: Registers the Slide post type used by the theme's homepage slider.
* Version: 1.0.0
*/
function mytheme_register_slides() {
register_post_type(
'slide',
array(
'labels' => array(
'name' => 'Slides',
'singular_name' => 'Slide',
'add_new_item' => 'Add New Slide',
'edit_item' => 'Edit Slide',
),
'public' => false,
'show_ui' => true,
'show_in_rest' => true,
'menu_icon' => 'dashicons-images-alt2',
'supports' => array( 'title', 'editor', 'thumbnail', 'custom-fields', 'page-attributes' ),
)
);
}
add_action( 'init', 'mytheme_register_slides' );
Must use plugins load automatically and cannot be switched off in the admin. WordPress only loads PHP files sitting directly in mu-plugins, not files in subfolders. A Slides menu now appears in the admin. Add two or three slides with a title, a short line of text and a featured image. To add a button, open the editor’s Preferences, turn on Custom fields, and add button_text and button_url fields to each slide. Use the Order field to set the slide order.
Step 10: Build the front page with the slider
Create front-page.php. It pulls the slides from step 9 with its own query and then reuses the header and footer.
<?php get_header(); ?>
<section class="slider">
<?php
$slides = new WP_Query(
array(
'post_type' => 'slide',
'posts_per_page' => 5,
'orderby' => 'menu_order',
'order' => 'ASC',
'no_found_rows' => true,
)
);
while ( $slides->have_posts() ) :
$slides->the_post();
$image = get_the_post_thumbnail_url( null, 'full' );
$button_text = get_post_meta( get_the_ID(), 'button_text', true );
$button_url = get_post_meta( get_the_ID(), 'button_url', true );
?>
<div class="single-slider"<?php echo $image ? ' style="background-image:url(' . esc_url( $image ) . ')"' : ''; ?>>
<div class="container">
<h2><?php the_title(); ?></h2>
<?php the_content(); ?>
<?php if ( $button_text && $button_url ) : ?>
<a class="btn" href="<?php echo esc_url( $button_url ); ?>"><?php echo esc_html( $button_text ); ?></a>
<?php endif; ?>
</div>
</div>
<?php
endwhile;
wp_reset_postdata();
?>
</section>
<!-- Paste the template's other homepage sections here -->
<?php get_footer(); ?>
Paste the rest of the template’s homepage sections below the slider, then wire up the template’s slider script as before. Every value printed into the page goes through esc_url() or esc_html(), so a slide cannot inject markup. wp_reset_postdata() puts the main query back after the loop.
One performance note: the first slide is usually the largest thing on the screen, and a CSS background image is found late by the browser. If you care about load speed, output the first slide’s image as an <img> element with the_post_thumbnail() instead.
Step 11: Blog page with pagination
Create home.php. WordPress uses it for the Posts page you set in step 3.
<?php get_header(); ?>
<main class="site-main blog-listing">
<?php if ( have_posts() ) : ?>
<div class="post-grid">
<?php
while ( have_posts() ) :
the_post();
?>
<article <?php post_class( 'post-card' ); ?>>
<a href="<?php the_permalink(); ?>">
<?php the_post_thumbnail( 'medium_large' ); ?>
<h2><?php the_title(); ?></h2>
</a>
<?php the_excerpt(); ?>
</article>
<?php endwhile; ?>
</div>
<?php
the_posts_pagination(
array(
'prev_text' => ( 'Previous', 'my-custom-theme' ),
'next_text' => ( 'Next', 'my-custom-theme' ),
)
);
?>
<?php else : ?>
<p><?php esc_html_e( 'No posts yet.', 'my-custom-theme' ); ?></p>
<?php endif; ?>
</main>
<?php get_sidebar(); ?>
<?php get_footer(); ?>
the_posts_pagination() prints numbered page links, using the number of posts per page set in Settings > Reading. Style the output with the template’s pagination CSS.
Step 12: Single post template
Create single.php for individual posts:
<?php get_header(); ?>
<main class="site-main">
<?php
while ( have_posts() ) :
the_post();
?>
<article <?php post_class( 'single-post' ); ?>>
<h1><?php the_title(); ?></h1>
<p class="meta">
<?php echo esc_html( get_the_date() ); ?> ·
<?php the_author(); ?> ·
<?php the_category( ', ' ); ?>
</p>
<?php the_post_thumbnail( 'large' ); ?>
<div class="entry-content">
<?php the_content(); ?>
</div>
<?php the_tags( '<p class="tags">' . esc_html( 'Tags: ', 'my-custom-theme' ), ', ', '</p>' ); ?>
</article>
<?php the_post_navigation(); ?>
<?php
if ( comments_open() || get_comments_number() ) {
comments_template();
}
endwhile;
?>
</main>
<?php get_sidebar(); ?>
<?php get_footer(); ?>
Step 13: Comments
comments_template() in the single template loads comments.php. Create it with the comment list and a comment form whose classes match the template’s design:
<?php
if ( post_password_required() ) {
return;
}
?>
<section id="comments" class="comments-area">
<?php if ( have_comments() ) : ?>
<h2 class="comments-title">
<?php
$count = get_comments_number();
printf(
/* translators: %s: number of comments */
esc_html( _n( '%s comment', '%s comments', $count, 'my-custom-theme' ) ),
esc_html( number_format_i18n( $count ) )
);
?>
</h2>
<ol class="comment-list">
<?php
wp_list_comments(
array(
'style' => 'ol',
'short_ping' => true,
'avatar_size' => 48,
)
);
?>
</ol>
<?php the_comments_pagination(); ?>
<?php endif; ?>
<?php if ( ! comments_open() && get_comments_number() ) : ?>
<p class="no-comments"><?php esc_html_e( 'Comments are closed.', 'my-custom-theme' ); ?></p>
<?php endif; ?>
<?php
comment_form(
array(
'title_reply' => ( 'Leave a reply', 'my-custom-theme' ),
'label_submit' => ( 'Post comment', 'my-custom-theme' ),
'class_form' => 'comment-form custom-comment-form',
'class_submit' => 'btn primary',
)
);
?>
</section>
comment_form() takes care of the fields, the nonce and the logged in state. Passing your own classes is usually enough to make it match the template; you rarely need to rebuild the form by hand.
Step 14: Sidebar and widgets
Register a widget area in functions.php:
function mytheme_widgets_init() {
register_sidebar(
array(
'name' => ( 'Sidebar', 'my-custom-theme' ),
'id' => 'sidebar-1',
'before_widget' => '<section id="%1$s" class="widget %2$s">',
'after_widget' => '</section>',
'before_title' => '<h2 class="widget-title">',
'after_title' => '</h2>',
)
);
}
add_action( 'widgets_init', 'mytheme_widgets_init' );
Create sidebar.php. The single and blog templates above already call it with get_sidebar().
<?php if ( is_active_sidebar( 'sidebar-1' ) ) : ?>
<aside class="sidebar">
<?php dynamic_sidebar( 'sidebar-1' ); ?>
</aside>
<?php endif; ?>
Go to Appearance > Widgets and add blocks to the Sidebar area: Latest Posts, Tag Cloud, Search, and the categories block (called Terms List in recent versions of WordPress). They pick up the before_widget and before_title markup you registered.
Step 15: Page and 404 templates, then check the theme
Create page.php for static pages:
<?php get_header(); ?>
<main class="site-main">
<?php
while ( have_posts() ) :
the_post();
the_title( '<h1>', '</h1>' );
the_content();
endwhile;
?>
</main>
<?php get_footer(); ?>
And 404.php for missing pages:
<?php get_header(); ?>
<main class="site-main">
<h1><?php esc_html_e( 'Page not found', 'my-custom-theme' ); ?></h1>
<p><?php esc_html_e( 'Try a search, or go back to the homepage.', 'my-custom-theme' ); ?></p>
<?php get_search_form(); ?>
</main>
<?php get_footer(); ?>
Before you call it finished:
- Turn on debugging in
wp-config.phpwhile you build, and readwp-content/debug.logafter clicking through every template:define( 'WP_DEBUG', true ); define( 'WP_DEBUG_LOG', true ); define( 'WP_DEBUG_DISPLAY', false ); - Run the free Theme Check plugin and fix what it reports.
- Check the homepage, blog page, a single post with comments, a page, a category archive, a search and a 404 at phone width.
- Untick “Discourage search engines” in Settings > Reading when the site goes live, and turn debugging off.
Which file controls which page
| Page | Template file (falls back to index.php) |
|---|---|
| Homepage | front-page.php |
| Blog posts page | home.php |
| Single post | single.php |
| Static page | page.php |
| Category archive | category.php, then archive.php |
| Tag archive | tag.php, then archive.php |
| Search results | search.php |
| Not found | 404.php |
| Every page | header.php, footer.php, sidebar.php |
| Theme setup and hooks | functions.php |
Common errors and what they mean
| What you see | Cause | Fix |
|---|---|---|
| Appearance > Themes lists it under “Broken Themes” with “Stylesheet is missing.” | style.css is missing, or the theme is one folder too deep (common after unzipping) |
Put style.css directly inside the theme folder, with the Theme Name: header |
| “Template is missing. Standalone themes need to have a templates/index.html or index.php template file.” | No index.php |
Add index.php to the theme folder |
| “There has been a critical error on this website.” | A PHP fatal error | Turn on WP_DEBUG_LOG and read wp-content/debug.log; it names the file and line |
| “Uncaught Error: Call to undefined function …” | A typo in a function name, or a function from a plugin that is not active | Check the spelling against the WordPress code reference |
| “Warning: Cannot modify header information”, ending “headers already sent by (output started at …/functions.php:…)” | Space or a blank line before <?php or after a closing ?> |
Remove it, and drop the closing ?> from functions.php |
| Styles or scripts missing on the front end | wp_head() or wp_footer() missing, or a wrong file path in the enqueue |
Put both calls back; check the file addresses in your browser’s Network tab |
| Admin bar missing on the front end when logged in | wp_footer() missing |
Add it before </body> |
| Page title missing or printed twice | A hard coded <title> left in the header alongside title-tag support, or neither |
Remove the tag and keep add_theme_support( 'title-tag' ) |
| No Featured image panel in the editor | No post-thumbnails support |
Add add_theme_support( 'post-thumbnails' ) |
| Menu does not appear | No menu assigned to the location | Appearance > Menus, tick the display location, save |
| “The theme you are currently using is not widget-aware…” | No widget area registered | Add the register_sidebar() code from step 14 |
| Slides menu missing | The file is in plugins or a subfolder of mu-plugins |
Move it directly into wp-content/mu-plugins/ |
What the video covers that this guide leaves out
The video walkthrough also sets up Tailwind CSS with live reload, runs a Redis object cache test and builds a small custom media upload plugin with its own database table. They are useful, but none of them is needed to build a working theme, so they are not repeated here. If you add Tailwind, have it build to its own file in css/ rather than to style.css, or every build will wipe out the theme header from step 1.
Next, try a child theme of your new theme, or add add_theme_support( 'woocommerce' ) if the site will sell online. The list of useful WordPress functions covers many of the template tags used above, and the custom post type guide goes further than the Slide type in step 9.
If you would rather not build it yourself, you can have a custom theme built for your business.
Frequently asked questions
What files does a WordPress theme need?
A classic theme needs only style.css with a Theme Name: header and index.php. A block theme needs style.css and templates/index.html. Everything else is optional and falls back through the template hierarchy.
Should I build a classic theme or a block theme in 2026?
If the site owner wants to change layouts in the Site Editor, build a block theme. If you are converting an existing HTML design and want the layout fixed in code, a classic theme is still fully supported and often quicker.
Why put custom post types in a plugin instead of functions.php?
Content belongs to the site, not the design. If the post type is registered in the theme and the theme is changed, the content stays in the database but disappears from the admin and the front end. A plugin or must use plugin keeps it registered whatever theme is active.
How long does it take to convert an HTML template to WordPress?
It depends on the template and how much of it must be editable from the admin. A small template with a blog is a manageable project for someone following these steps; sliders, custom fields and WooCommerce add time.
Be the first to comment