WordPress Starter Theme Guide: Build Custom Sites From Scratch
Pre-built themes add 300–800KB of CSS you don't need, load 15+ scripts you never use, and lock you into someone else's design decisions. A custom theme built from a starter like Underscores (_s) or Sage gives you complete control, clean markup, exact performance, and pixel-perfect design. This guide covers everything: starter theme comparison, file structure, template hierarchy, functions.php, the Loop, Customizer API, block theme (FSE) support, and production optimization.

Web hosting enthusiast who tests providers and breaks down features, pricing, and real world speed

Why Build a Custom WordPress Theme?
| Factor | Pre-Built Theme (Astra, Divi) | Custom Starter Theme |
|---|---|---|
| Page weight (CSS + JS) | 300–800KB+ (unused styles included) | 30–80KB (only what you write) |
| HTTP requests | 12–25+ scripts/styles | 3–6 optimized bundles |
| Lighthouse Performance | 50–75 (typical) | 90–100 (achievable) |
| Design flexibility | Limited to theme options/builder | Unlimited; you control every pixel |
| Code quality | Generic, bloated, backward-compatible | Clean, semantic, purpose-built |
| SEO markup | Generic schema, often missing | Custom structured data, semantic HTML5 |
| Update dependency | Theme updates can break customizations | You control all updates |
| Client handoff | Easy (visual builder) | Requires training or Customizer/Gutenberg setup |
| Development time | Hours (configure & customize) | Days to weeks (build from scratch) |
| Long-term maintenance | Dependent on theme vendor updates | Full ownership, no vendor lock-in |
Best Starter Themes Compared
Underscores (_s)
Best Classic Starter9.4/10Pros
Cons
Sage (Roots)
Best Modern Workflow9.2/10Pros
Cons
Flavor Theme
Best Lightweight8.6/10Pros
Cons
theme.json (Block Theme)
Best for Gutenberg/FSE9.0/10Pros
Cons
Tailpress
Best for Tailwind CSS8.5/10Pros
Cons
| Feature | _s | Sage | Block Theme | Tailpress | Flavor |
|---|---|---|---|---|---|
| Approach | Classic PHP | Blade + MVC | HTML + theme.json | PHP + Tailwind | Minimal PHP |
| Build tools | ❌ BYO | ✅ Bud.js (Vite) | ❌ None needed | ✅ esbuild | ❌ BYO |
| Tailwind CSS | ❌ | ✅ Pre-configured | ❌ | ✅ Core feature | ❌ |
| FSE / Block ready | ⚠️ Add manually | ⚠️ Add manually | ✅ Native | ⚠️ Add manually | ⚠️ Add manually |
| Learning curve | Low | High | Medium | Low: Medium | Low |
| WooCommerce ready | ⚠️ Add manually | ✅ (community) | ⚠️ Limited | ⚠️ Add manually | ⚠️ Add manually |
| Theme size | ~50KB | ~200KB (with deps) | ~10KB | ~80KB | ~20KB |
| Community size | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ (growing) | ⭐⭐⭐ | ⭐⭐ |
| Best for | Most developers | Agencies | Future-proof sites | Tailwind fans | Ultra-minimal |
WordPress Theme File Structure
your-theme/ ├── style.css ← Theme metadata + base styles (REQUIRED) ├── index.php ← Ultimate fallback template (REQUIRED) ├── functions.php ← Theme setup, enqueue, menus, sidebars ├── screenshot.png ← Theme thumbnail (1200×900px recommended) │ ├── header.php ← <head> + opening <body> + site header ├── footer.php ← Site footer + closing </body> ├── sidebar.php ← Widget area template │ ├── single.php ← Individual blog post ├── page.php ← Static page ├── archive.php ← Post listings (category, tag, date, author) ├── search.php ← Search results ├── 404.php ← Not found page ├── front-page.php ← Homepage (if set to static page) ├── home.php ← Blog posts page │ ├── template-parts/ ← Reusable template components │ ├── content.php ← Post content in The Loop │ ├── content-single.php ← Single post content │ ├── content-page.php ← Page content │ └── content-none.php ← No results found │ ├── inc/ ← PHP includes (modular functions) │ ├── customizer.php ← Theme Customizer settings │ ├── template-tags.php ← Custom template functions │ └── template-functions.php │ ├── assets/ ← Static assets │ ├── css/ │ │ └── main.css ← Compiled/custom styles │ ├── js/ │ │ └── main.js ← Theme JavaScript │ ├── images/ │ └── fonts/ │ ├── languages/ ← Translation files │ └── theme-slug.pot │ └── theme.json ← Block editor settings (optional for classic)
Step 1: Local Development Setup
| Tool | Type | Cost | Best For | Setup Time |
|---|---|---|---|---|
| LocalWP | GUI app (Mac/Win/Linux) | Free | Most developers, one-click WP install | 2 minutes |
| DDEV | Docker-based CLI | Free | Teams, reproducible environments | 5 minutes |
| wp-env | Docker (official WP) | Free | Block theme / plugin development | 3 minutes |
| Lando | Docker-based CLI | Free | Complex setups (Redis, ES, etc.) | 10 minutes |
| MAMP / XAMPP | Traditional AMP stack | Free / Pro $39 | Beginners, familiar setup | 5 minutes |
Quick start with Underscores (_s)
# 1. Install LocalWP and create a new WordPress site # 2. Navigate to your themes directory cd ~/Local Sites/my-site/app/public/wp-content/themes/ # 3. Option A: Generate from underscores.me (download + unzip) # Visit https://underscores.me → enter theme name → download # 4. Option B: Clone from GitHub git clone https://github.com/Automattic/_s.git my-theme cd my-theme # 5. Search-replace '_s' with your theme slug # Replace '_s' with 'my_theme' in all files (functions, text domain, etc.) # 6. Activate in WP Admin → Appearance → Themes
Step 2: Create Core Theme Files
style.css, Theme header (required)
/*
Theme Name: My Custom Theme
Theme URI: https://yourdomain.com/my-theme
Author: Your Name
Author URI: https://yourdomain.com
Description: A lightweight, performance-focused custom WordPress theme.
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-theme
Tags: custom-logo, custom-menu, featured-images, threaded-comments,
translation-ready, block-styles, wide-blocks
*/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 id="site-header" class="site-header" role="banner">
<div class="site-branding">
<?php if (has_custom_logo()) : ?>
<?php the_custom_logo(); ?>
<?php else : ?>
<a href="<?php echo esc_url(home_url('/')); ?>">
<?php bloginfo('name'); ?>
</a>
<?php endif; ?>
</div>
<nav id="site-navigation" class="main-navigation" role="navigation"
aria-label="<?php esc_attr_e('Primary Menu', 'my-theme'); ?>">
<?php wp_nav_menu(array(
'theme_location' => 'primary',
'menu_id' => 'primary-menu',
'container' => false,
'fallback_cb' => false,
)); ?>
</nav>
</header>footer.php
<footer id="site-footer" class="site-footer" role="contentinfo">
<div class="footer-widgets">
<?php if (is_active_sidebar('footer-1')) : ?>
<?php dynamic_sidebar('footer-1'); ?>
<?php endif; ?>
</div>
<div class="site-info">
<p>© <?php echo date('Y'); ?>
<?php bloginfo('name'); ?>.
<?php esc_html_e('All rights reserved.', 'my-theme'); ?>
</p>
</div>
</footer>
<?php wp_footer(); ?>
</body>
</html>Step 3: Template Hierarchy
WordPress uses a cascading template system, it looks for the most specific template first, then falls back to more general ones. Understanding this is the foundation of theme development.
| Page Type | Template Priority (most → least specific) | Example |
|---|---|---|
| Single Post | single-{post-type}-{slug}.php → single-{post-type}.php → single.php → singular.php → index.php | single-post-hello-world.php |
| Static Page | page-{slug}.php → page-{id}.php → page.php → singular.php → index.php | page-about.php |
| Category Archive | category-{slug}.php → category-{id}.php → category.php → archive.php → index.php | category-tutorials.php |
| Custom Post Type | archive-{post-type}.php → archive.php → index.php | archive-portfolio.php |
| Author Archive | author-{nicename}.php → author-{id}.php → author.php → archive.php → index.php | author-mallory.php |
| Search Results | search.php → index.php | search.php |
| 404 Error | 404.php → index.php | 404.php |
| Homepage | front-page.php → home.php → index.php | front-page.php |
Step 4: functions.php Essentials
functions.php, Complete starter configuration
<?php
/**
* Theme functions and definitions.
*/
// === THEME SETUP ===
function mytheme_setup() {
// Add title tag support
add_theme_support('title-tag');
// Featured images
add_theme_support('post-thumbnails');
set_post_thumbnail_size(1200, 630, true);
add_image_size('hero', 1920, 800, true);
add_image_size('card', 600, 400, true);
// Custom logo
add_theme_support('custom-logo', array(
'height' => 100,
'width' => 400,
'flex-height' => true,
'flex-width' => true,
));
// HTML5 markup
add_theme_support('html5', array(
'search-form', 'comment-form', 'comment-list',
'gallery', 'caption', 'style', 'script',
));
// Block editor support
add_theme_support('wp-block-styles');
add_theme_support('align-wide');
add_theme_support('editor-styles');
add_editor_style('assets/css/editor-style.css');
add_theme_support('responsive-embeds');
// Register navigation menus
register_nav_menus(array(
'primary' => __('Primary Menu', 'my-theme'),
'footer' => __('Footer Menu', 'my-theme'),
));
}
add_action('after_setup_theme', 'mytheme_setup');
// === ENQUEUE STYLES & SCRIPTS ===
function mytheme_scripts() {
// Main stylesheet
wp_enqueue_style(
'mytheme-style',
get_template_directory_uri() . '/assets/css/main.css',
array(),
filemtime(get_template_directory() . '/assets/css/main.css')
);
// Theme JavaScript (in footer)
wp_enqueue_script(
'mytheme-script',
get_template_directory_uri() . '/assets/js/main.js',
array(),
filemtime(get_template_directory() . '/assets/js/main.js'),
true // Load in footer
);
// Comment reply script (only when needed)
if (is_singular() && comments_open()) {
wp_enqueue_script('comment-reply');
}
}
add_action('wp_enqueue_scripts', 'mytheme_scripts');
// === REGISTER SIDEBARS / WIDGET AREAS ===
function mytheme_widgets_init() {
register_sidebar(array(
'name' => __('Sidebar', 'my-theme'),
'id' => 'sidebar-1',
'before_widget' => '<section class="widget %2$s">',
'after_widget' => '</section>',
'before_title' => '<h3 class="widget-title">',
'after_title' => '</h3>',
));
register_sidebar(array(
'name' => __('Footer Widgets', 'my-theme'),
'id' => 'footer-1',
'before_widget' => '<div class="footer-widget %2$s">',
'after_widget' => '</div>',
'before_title' => '<h4 class="widget-title">',
'after_title' => '</h4>',
));
}
add_action('widgets_init', 'mytheme_widgets_init');
// === PERFORMANCE: Remove unnecessary features ===
// Remove emoji scripts
remove_action('wp_head', 'print_emoji_detection_script', 7);
remove_action('wp_print_styles', 'print_emoji_styles');
// Remove oEmbed discovery
remove_action('wp_head', 'wp_oembed_add_discovery_links');
// Disable XML-RPC (security + performance)
add_filter('xmlrpc_enabled', '__return_false');Step 5: The WordPress Loop
The Loop is WordPress's mechanism for displaying posts. Every template that shows content uses some form of The Loop.
index.php, Basic Loop with template parts
<?php get_header(); ?>
<main id="primary" class="site-main" role="main">
<?php if (have_posts()) : ?>
<?php if (is_home() && !is_front_page()) : ?>
<header class="page-header">
<h1 class="page-title"><?php single_post_title(); ?></h1>
</header>
<?php endif; ?>
<div class="posts-grid">
<?php while (have_posts()) : the_post(); ?>
<?php get_template_part('template-parts/content', get_post_type()); ?>
<?php endwhile; ?>
</div>
<?php the_posts_pagination(array(
'prev_text' => __('← Previous', 'my-theme'),
'next_text' => __('Next →', 'my-theme'),
)); ?>
<?php else : ?>
<?php get_template_part('template-parts/content', 'none'); ?>
<?php endif; ?>
</main>
<?php get_sidebar(); ?>
<?php get_footer(); ?>template-parts/content.php, Post card in The Loop
<article id="post-<?php the_ID(); ?>" <?php post_class('post-card'); ?>>
<?php if (has_post_thumbnail()) : ?>
<a href="<?php the_permalink(); ?>" class="post-thumbnail">
<?php the_post_thumbnail('card', array(
'loading' => 'lazy',
'alt' => the_title_attribute(array('echo' => false)),
)); ?>
</a>
<?php endif; ?>
<div class="post-card-content">
<header class="entry-header">
<?php the_title(sprintf(
'<h2 class="entry-title"><a href="%s">',
esc_url(get_permalink())
), '</a></h2>'); ?>
<div class="entry-meta">
<time datetime="<?php echo get_the_date('c'); ?>">
<?php echo get_the_date(); ?>
</time>
<span class="author">
<?php the_author_posts_link(); ?>
</span>
</div>
</header>
<div class="entry-excerpt">
<?php the_excerpt(); ?>
</div>
</div>
</article>Step 6: Theme Customizer API
The Customizer lets clients edit theme settings with live preview, without touching code.
inc/customizer.php, Register custom settings
<?php
function mytheme_customize_register($wp_customize) {
// === HERO SECTION ===
$wp_customize->add_section('mytheme_hero', array(
'title' => __('Hero Section', 'my-theme'),
'priority' => 30,
));
// Hero heading
$wp_customize->add_setting('hero_heading', array(
'default' => 'Welcome to Our Site',
'sanitize_callback' => 'sanitize_text_field',
'transport' => 'postMessage', // Live preview
));
$wp_customize->add_control('hero_heading', array(
'label' => __('Hero Heading', 'my-theme'),
'section' => 'mytheme_hero',
'type' => 'text',
));
// Hero CTA button text
$wp_customize->add_setting('hero_cta_text', array(
'default' => 'Get Started',
'sanitize_callback' => 'sanitize_text_field',
));
$wp_customize->add_control('hero_cta_text', array(
'label' => __('CTA Button Text', 'my-theme'),
'section' => 'mytheme_hero',
'type' => 'text',
));
// Primary color
$wp_customize->add_setting('primary_color', array(
'default' => '#2563eb',
'sanitize_callback' => 'sanitize_hex_color',
'transport' => 'postMessage',
));
$wp_customize->add_control(new WP_Customize_Color_Control(
$wp_customize, 'primary_color', array(
'label' => __('Primary Color', 'my-theme'),
'section' => 'colors',
)
));
}
add_action('customize_register', 'mytheme_customize_register');
// Output custom CSS from Customizer settings
function mytheme_customizer_css() {
$primary = get_theme_mod('primary_color', '#2563eb');
?>
<style>
:root { --color-primary: <?php echo esc_attr($primary); ?>; }
</style>
<?php
}
add_action('wp_head', 'mytheme_customizer_css');Step 7: Block Theme (FSE) Support
Full Site Editing (FSE) is the future of WordPress theming. Even if you build a classic theme, add theme.json for block editor integration.
theme.json, Design tokens and block settings
{
"$schema": "https://schemas.wp.org/wp/6.7/theme.json",
"version": 3,
"settings": {
"color": {
"palette": [
{ "slug": "primary", "color": "#2563eb", "name": "Primary" },
{ "slug": "secondary", "color": "#7c3aed", "name": "Secondary" },
{ "slug": "dark", "color": "#1e293b", "name": "Dark" },
{ "slug": "light", "color": "#f8fafc", "name": "Light" }
],
"gradients": [
{
"slug": "primary-to-secondary",
"gradient": "linear-gradient(135deg, #2563eb, #7c3aed)",
"name": "Primary to Secondary"
}
]
},
"typography": {
"fontFamilies": [
{
"fontFamily": "'Inter', sans-serif",
"slug": "body",
"name": "Body"
},
{
"fontFamily": "'Playfair Display', serif",
"slug": "heading",
"name": "Heading"
}
],
"fontSizes": [
{ "slug": "small", "size": "0.875rem", "name": "Small" },
{ "slug": "medium", "size": "1rem", "name": "Medium" },
{ "slug": "large", "size": "1.5rem", "name": "Large" },
{ "slug": "x-large", "size": "2.5rem", "name": "Extra Large" }
]
},
"spacing": {
"units": ["px", "rem", "%", "vw"],
"padding": true,
"margin": true
},
"layout": {
"contentSize": "768px",
"wideSize": "1200px"
}
},
"styles": {
"color": {
"background": "var(--wp--preset--color--light)",
"text": "var(--wp--preset--color--dark)"
},
"typography": {
"fontFamily": "var(--wp--preset--font-family--body)",
"fontSize": "var(--wp--preset--font-size--medium)",
"lineHeight": "1.6"
},
"elements": {
"h1": {
"typography": {
"fontFamily": "var(--wp--preset--font-family--heading)",
"fontSize": "var(--wp--preset--font-size--x-large)"
}
},
"link": {
"color": { "text": "var(--wp--preset--color--primary)" }
}
}
}
}Step 8: Performance Optimization
| Optimization | Implementation | Impact |
|---|---|---|
| Defer non-critical JS | wp_script_add_data('handle', 'strategy', 'defer') | Reduces render-blocking by 200–500ms |
| Lazy load images | loading="lazy" on <img> tags (WP 5.5+ auto) | Saves 100–500KB on initial load |
| Use srcset for responsive images | wp_get_attachment_image() generates srcset automatically | 30–70% image bandwidth savings on mobile |
| Preload critical assets | wp_preload_resources filter or <link rel='preload'> | 50–200ms faster LCP (fonts, hero image) |
| Remove block library CSS | wp_dequeue_style('wp-block-library') if not using blocks | Saves ~40KB unused CSS |
| Remove jQuery (if unused) | wp_dequeue_script('jquery') + rewrite in vanilla JS | Saves ~90KB + eliminates dependency chain |
| Use transient caching | set_transient() for expensive queries (1–12hr cache) | Reduces DB queries by 50–80% on complex pages |
| Minify CSS/JS in production | Build tool (esbuild, Vite) or Autoptimize plugin | 20–40% smaller file sizes |
| Critical CSS inline | Inline above-the-fold CSS in <head>, defer rest | Eliminates render-blocking stylesheet |
| Async load web fonts | font-display: swap in @font-face + preload key fonts | Prevents FOIT (Flash of Invisible Text) |
Step 9: Security Hardening
| Security Measure | Implementation | What It Prevents |
|---|---|---|
| Escape all output | esc_html(), esc_attr(), esc_url(), wp_kses_post() | XSS (Cross-Site Scripting) attacks |
| Sanitize all input | sanitize_text_field(), absint(), sanitize_email() | SQL injection, data corruption |
| Use nonces for forms | wp_nonce_field() + wp_verify_nonce() | CSRF (Cross-Site Request Forgery) |
| Prefix everything | mytheme_ prefix on functions, classes, handles | Namespace collisions with plugins/core |
| Validate capabilities | current_user_can('edit_posts') before actions | Privilege escalation |
| Hide WordPress version | remove_action('wp_head', 'wp_generator') | Version-targeted exploits |
| Disable file editing | define('DISALLOW_FILE_EDIT', true) in wp-config.php | Backdoor injection via Theme Editor |
| Use prepared statements | $wpdb->prepare() for all custom queries | SQL injection attacks |
Best Hosting for Theme Development
| Host | Staging | Git Deploy | SSH Access | WP-CLI | Price |
|---|---|---|---|---|---|
| Kinsta | ✅ One-click | ✅ (SSH-based) | ✅ | ✅ | From $35/mo |
| Cloudways | ✅ One-click | ✅ (Git webhook) | ✅ | ✅ | From $14/mo |
| SiteGround | ✅ (GoGeek+) | ✅ (Git) | ✅ | ✅ | From $2.99/mo |
| WP Engine | ✅ Built-in | ✅ (Git push) | ✅ (SFTP/SSH) | ✅ | From $20/mo |
| Pressidium | ✅ Built-in | ❌ | ✅ | ✅ | From $25/mo |
| Flywheel | ✅ Built-in | ❌ | ✅ (SFTP) | ❌ | From $15/mo |
Theme Development Checklist
Before Launch ✅
Performance & Security ✅
Frequently Asked Questions
Should I build a classic PHP theme or a block theme (FSE)?
What's the minimum files needed for a WordPress theme?
How do I make my custom theme fast?
How do I add WooCommerce support to my custom theme?
What's the WordPress template hierarchy and why does it matter?
How do I set up a local WordPress development environment?
How do I make my theme translation-ready?
Should I use a page builder instead of a custom theme?
Need WordPress Hosting for Theme Development?
Compare managed WordPress hosts with staging environments, Git deployment, SSH access, and WP-CLI, everything a theme developer needs.
Find Developer-Friendly Hosting