Luke Anderson WordPress

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

  1. Create two pages under Pages > Add New: one called Home and one called Blog.
  2. Go to Settings > Reading. Under “Your homepage displays”, choose a static page, set Homepage to Home and Posts page to Blog.
  3. 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.
  4. 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>&copy; <?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() ); ?> &middot;
				<?php the_author(); ?> &middot;
				<?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:

  1. Turn on debugging in wp-config.php while you build, and read wp-content/debug.log after clicking through every template:
    define( 'WP_DEBUG', true );
    define( 'WP_DEBUG_LOG', true );
    define( 'WP_DEBUG_DISPLAY', false );
  2. Run the free Theme Check plugin and fix what it reports.
  3. Check the homepage, blog page, a single post with comments, a page, a category archive, a search and a 404 at phone width.
  4. 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.

Discussion

Be the first to comment

Leave a comment

Get in touch