WordPress · 8 min read · January 2025

How to Create a WordPress Theme from Scratch

Theme frameworks and page builders hide the mechanics of WordPress. Building a theme by hand teaches you template hierarchy, the Loop, and the hook system — the knowledge that makes you a faster, more confident developer on every future project.

The Minimum Viable Theme

WordPress requires exactly two files to recognise a directory as a valid theme: style.css and index.php. That's it. Everything else — header.php, footer.php, single.php — is optional and loaded by the template hierarchy only when it exists.

Create a folder inside wp-content/themes/, for example my-theme/, and add these two files:

/*
 * Theme Name:  My Theme
 * Theme URI:   https://lucasnmoura.com
 * Author:      Lucas Moura
 * Description: Custom theme built from scratch.
 * Version:     1.0.0
 * License:     GPL-2.0-or-later
 * Text Domain: my-theme
 */

That comment block in style.css is the theme manifest — WordPress reads it to populate the Appearance > Themes screen. Activate the theme and you have a working (blank) site.

Recommended File Structure

A clean, scalable theme follows a predictable layout that separates concerns and maps directly to WordPress's template hierarchy:

my-theme/
├── style.css           # Theme manifest + global styles
├── functions.php       # Hooks, enqueue, theme support
├── index.php           # Fallback template (required)
├── header.php          # get_header() target
├── footer.php          # get_footer() target
├── single.php          # Single post view
├── page.php            # Static page view
├── archive.php         # Post archive
├── search.php          # Search results
├── 404.php             # Not found
├── template-parts/
│   ├── content.php
│   ├── content-single.php
│   └── content-none.php
└── assets/
    ├── css/
    │   └── main.css
    └── js/
        └── main.js

Understanding the Template Hierarchy

When a visitor hits a URL, WordPress walks a decision tree to find the right template file. For a single post, the lookup order is:

  1. single-{post-type}-{slug}.php
  2. single-{post-type}.php
  3. single.php
  4. singular.php
  5. index.php

WordPress uses the first file it finds. This means you can create a single-product.php to handle WooCommerce products differently from regular posts without touching a plugin file.

Pro tip: The official Template Hierarchy visualiser on developer.wordpress.org is the single most useful reference you'll bookmark.

functions.php — The Theme's Engine

Think of functions.php as a theme-scoped plugin. It runs on every request and is the right place to declare theme support, register menus, enqueue assets, and add custom hooks.

<?php
// Theme setup
add_action( 'after_setup_theme', function() {
    add_theme_support( 'title-tag' );
    add_theme_support( 'post-thumbnails' );
    add_theme_support( 'html5', [
        'search-form', 'comment-form', 'gallery', 'caption',
    ] );

    register_nav_menus( [
        'primary' => __( 'Primary Menu', 'my-theme' ),
        'footer'  => __( 'Footer Menu',  'my-theme' ),
    ] );
} );

// Enqueue assets
add_action( 'wp_enqueue_scripts', function() {
    wp_enqueue_style(
        'my-theme-style',
        get_template_directory_uri() . '/assets/css/main.css',
        [],
        wp_get_theme()->get( 'Version' )
    );

    wp_enqueue_script(
        'my-theme-script',
        get_template_directory_uri() . '/assets/js/main.js',
        [],
        wp_get_theme()->get( 'Version' ),
        true // load in footer
    );
} );

Always version your assets using the theme version number. This busts the cache automatically whenever you update the theme.

The Loop

The Loop is the core pattern that powers every WordPress template. It iterates over the query results and outputs whatever HTML you place inside it:

<?php if ( have_posts() ) : ?>
    <?php while ( have_posts() ) : the_post(); ?>
        <article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
            <header>
                <h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
                <time datetime="<?php echo get_the_date( 'c' ); ?>">
                    <?php echo get_the_date(); ?>
                </time>
            </header>
            <div class="entry-content">
                <?php the_content(); ?>
            </div>
        </article>
    <?php endwhile; ?>
<?php else : ?>
    <p>No posts found.</p>
<?php endif; ?>

Split your layout with get_header() and get_footer(). These functions include header.php and footer.php respectively. A minimal 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(); ?> <!-- NEVER remove this -->
</head>
<body <?php body_class(); ?>>
<?php wp_body_open(); ?>
<header class="site-header">
    <a href="<?php echo home_url('/'); ?>">
        <?php bloginfo( 'name' ); ?>
    </a>
    <nav>
        <?php wp_nav_menu( [ 'theme_location' => 'primary' ] ); ?>
    </nav>
</header>
Never remove wp_head() and wp_footer(). Plugins and WordPress core rely on these hooks to inject scripts, styles, and meta tags. Removing them breaks half your plugins silently.

Template Parts

Use get_template_part() to extract reusable chunks out of your templates. This keeps individual template files short and composable:

<?php
// In index.php — delegate per-post HTML to a partial
while ( have_posts() ) :
    the_post();
    get_template_part( 'template-parts/content', get_post_type() );
endwhile;

WordPress will look for template-parts/content-post.php first, then fall back to template-parts/content.php. This mirrors the template hierarchy pattern and lets you specialise output per post type without branching logic in the parent template.

Shipping a Production Theme

Before going live, run through this checklist:

  • All strings wrapped in __( 'text', 'my-theme' ) for i18n
  • All output escaped: esc_html(), esc_url(), esc_attr()
  • Assets versioned and conditionally loaded (only on pages that need them)
  • wp_head() and wp_footer() present in every layout
  • Theme tested with Theme Check plugin
  • Tested with WP_DEBUG and WP_DEBUG_LOG enabled

A hand-built theme is never "done" — but starting from zero teaches you exactly what page builders abstract away, and that knowledge compounds across every project you'll ever build.