
How to create a custom WordPress theme from scratch?
To create a custom WordPress theme from scratch, you create a new folder in wp-content/themes, add a style.css file with a theme header, and then add the templates WordPress needs to render your pages. In 2026, the recommended approach is a block theme: a theme.json file for design settings, HTML templates in a templates folder, and reusable header and footer parts in a parts folder. With just those files, you have a working theme you can activate and edit in the Site Editor.
This guide walks you through building a small but complete block theme step by step. You'll set up a local environment, write style.css and theme.json, create templates and template parts, add a pattern, enqueue custom CSS through functions.php, and package the theme. At the end, there's a short section on building a classic PHP theme in case you need one for an older project.
Before You Start
You don't need to be an expert developer to build a theme, but you should be comfortable with basic HTML, CSS, and editing files. A little PHP helps for functions.php.
You'll also need:
- A local development environment: Tools like LocalWP, DevKinsta, or
wp-env(from the@wordpress/envpackage) let you run WordPress on your computer. Never build a theme directly on a live site. - A code editor: VS Code, PhpStorm, or any editor you like.
- A recent version of WordPress: WordPress 6.6 or newer supports
theme.jsonversion 3, which this guide uses. - Debugging enabled: Add these lines to
wp-config.php, above the line that says "That's all, stop editing!", so you can see PHP errors while you work:
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
With WP_DEBUG_DISPLAY set to false, errors are written to wp-content/debug.log instead of being printed on the page.
If you prefer using wp-env, you can start a local site with Docker installed like this:
npm -g install @wordpress/env
mkdir my-theme-dev && cd my-theme-dev
wp-env start
Step 1: Plan Your Theme
Before writing code, decide a few basics:
- Name and slug: Pick a unique name such as "Sajjad Starter" and a lowercase slug like
sajjad-starter. The slug becomes the folder name and text domain. - Design system: Choose two or three brand colors, one or two font families, and a content width.
- Templates you need: At minimum, an index template. Most sites also want single post, page, archive, search, and 404 templates.
- Reusable sections: Usually a header and footer, possibly a sidebar or comments area.
Having this written down makes the theme.json step much easier.
Step 2: Create the Theme Folder and style.css
Go to wp-content/themes and create a folder named after your slug. Inside it, create style.css with this header:
/*
Theme Name: Sajjad Starter
Theme URI: https://example.com/sajjad-starter
Author: Sajjad
Author URI: https://example.com/
Description: A lightweight custom block theme built from scratch.
Requires at least: 6.6
Tested up to: 6.8
Requires PHP: 8.0
Version: 1.0.0
License: GNU General Public License v2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Text Domain: sajjad-starter
*/
Only Theme Name is strictly required, but the other fields help WordPress and users understand your theme. Adjust the "Tested up to" value to the WordPress version you actually test against.
Step 3: Add the Required Index Template
A block theme needs at least one template: templates/index.html. Create a templates folder and add index.html:
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<!-- wp:group {"tagName":"main","style":{"spacing":{"padding":{"top":"var:preset|spacing|50","bottom":"var:preset|spacing|50"}}},"layout":{"type":"constrained"}} -->
<main
class="wp-block-group"
style="padding-top:var(--wp--preset--spacing--50);padding-bottom:var(--wp--preset--spacing--50)"
>
<!-- wp:query {"queryId":1,"query":{"perPage":10,"postType":"post","inherit":true}} -->
<div class="wp-block-query">
<!-- wp:post-template -->
<!-- wp:post-title {"isLink":true,"level":2} /-->
<!-- wp:post-date /-->
<!-- wp:post-excerpt /-->
<!-- /wp:post-template -->
<!-- wp:query-pagination {"layout":{"type":"flex","justifyContent":"space-between"}} -->
<!-- wp:query-pagination-previous /-->
<!-- wp:query-pagination-numbers /-->
<!-- wp:query-pagination-next /-->
<!-- /wp:query-pagination -->
<!-- wp:query-no-results -->
<!-- wp:paragraph -->
<p>Nothing found.</p>
<!-- /wp:paragraph -->
<!-- /wp:query-no-results -->
</div>
<!-- /wp:query -->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
The "inherit":true setting tells the Query Loop to use the main WordPress query, so the same template works for the blog home, archives, and search results.
At this point, WordPress already recognizes your folder as a block theme. Go to Appearance > Themes and you'll see it listed. Don't activate it yet; let's add the header and footer first.
Step 4: Create Template Parts
Create a parts folder with header.html and footer.html.
parts/header.html:
<!-- wp:group {"style":{"spacing":{"padding":{"top":"var:preset|spacing|30","bottom":"var:preset|spacing|30"}}},"layout":{"type":"constrained"}} -->
<div
class="wp-block-group"
style="padding-top:var(--wp--preset--spacing--30);padding-bottom:var(--wp--preset--spacing--30)"
>
<!-- wp:group {"layout":{"type":"flex","justifyContent":"space-between","flexWrap":"wrap"}} -->
<div class="wp-block-group">
<!-- wp:group {"layout":{"type":"flex"}} -->
<div class="wp-block-group">
<!-- wp:site-logo {"width":48} /-->
<!-- wp:site-title {"level":0} /-->
</div>
<!-- /wp:group -->
<!-- wp:navigation {"overlayMenu":"mobile"} /-->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->
parts/footer.html:
<!-- wp:group {"style":{"spacing":{"padding":{"top":"var:preset|spacing|40","bottom":"var:preset|spacing|40"}}},"backgroundColor":"surface","layout":{"type":"constrained"}} -->
<div
class="wp-block-group has-surface-background-color has-background"
style="padding-top:var(--wp--preset--spacing--40);padding-bottom:var(--wp--preset--spacing--40)"
>
<!-- wp:group {"layout":{"type":"flex","justifyContent":"space-between","flexWrap":"wrap"}} -->
<div class="wp-block-group">
<!-- wp:site-title {"level":0} /-->
<!-- wp:paragraph {"fontSize":"small"} -->
<p class="has-small-font-size">Built with a custom WordPress theme.</p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->
The footer uses a surface color, which you'll define in theme.json next. Template parts should be registered in theme.json so WordPress knows which area they belong to.
Step 5: Configure theme.json
Create theme.json in the root of your theme. This file defines the design system and default styles:
{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 3,
"settings": {
"appearanceTools": true,
"useRootPaddingAwareAlignments": true,
"layout": {
"contentSize": "700px",
"wideSize": "1200px"
},
"color": {
"defaultPalette": false,
"palette": [
{ "slug": "base", "color": "#ffffff", "name": "Base" },
{ "slug": "surface", "color": "#f4f4f5", "name": "Surface" },
{ "slug": "contrast", "color": "#18181b", "name": "Contrast" },
{ "slug": "accent", "color": "#2563eb", "name": "Accent" }
]
},
"typography": {
"fluid": true,
"fontFamilies": [
{
"slug": "system",
"name": "System Sans",
"fontFamily": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
}
],
"fontSizes": [
{ "slug": "small", "size": "0.875rem", "name": "Small" },
{ "slug": "medium", "size": "1.125rem", "name": "Medium" },
{
"slug": "large",
"size": "1.75rem",
"name": "Large",
"fluid": { "min": "1.5rem", "max": "1.75rem" }
},
{
"slug": "x-large",
"size": "2.5rem",
"name": "Extra Large",
"fluid": { "min": "2rem", "max": "2.5rem" }
}
]
},
"spacing": {
"units": ["px", "rem", "%", "vw"]
}
},
"styles": {
"color": {
"background": "var(--wp--preset--color--base)",
"text": "var(--wp--preset--color--contrast)"
},
"typography": {
"fontFamily": "var(--wp--preset--font-family--system)",
"fontSize": "var(--wp--preset--font-size--medium)",
"lineHeight": "1.6"
},
"spacing": {
"padding": {
"left": "var:preset|spacing|40",
"right": "var:preset|spacing|40"
}
},
"elements": {
"link": {
"color": { "text": "var(--wp--preset--color--accent)" },
":hover": { "typography": { "textDecoration": "none" } }
},
"h1": {
"typography": { "fontSize": "var(--wp--preset--font-size--x-large)" }
},
"h2": {
"typography": { "fontSize": "var(--wp--preset--font-size--large)" }
},
"button": {
"color": {
"background": "var(--wp--preset--color--accent)",
"text": "var(--wp--preset--color--base)"
},
"border": { "radius": "6px" }
}
}
},
"templateParts": [
{ "name": "header", "title": "Header", "area": "header" },
{ "name": "footer", "title": "Footer", "area": "footer" }
]
}
A few things to note:
appearanceToolsturns on border, spacing, typography, and other controls in the editor.defaultPalette: falsehides WordPress's built-in color palette so users only see your brand colors.- Spacing presets like
var:preset|spacing|40come from WordPress's default spacing scale, which runs from 20 to 80. You can define your own withspacingSizesif you prefer. templatePartsregisters the header and footer so they appear in the correct areas of the Site Editor.
Now go to Appearance > Themes and activate your theme. Visit the front end and you should see a header, a list of posts, and a footer.
Step 6: Add More Templates
With the basic structure working, add templates for other contexts. WordPress uses the template hierarchy to choose the most specific template available, falling back to index.html.
templates/single.html for individual posts:
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:post-title {"level":1} /-->
<!-- wp:group {"layout":{"type":"flex"}} -->
<div class="wp-block-group">
<!-- wp:post-date /-->
<!-- wp:post-author-name /-->
<!-- wp:post-terms {"term":"category"} /-->
</div>
<!-- /wp:group -->
<!-- wp:post-featured-image {"align":"wide"} /-->
<!-- wp:post-content {"layout":{"type":"constrained"}} /-->
<!-- wp:post-terms {"term":"post_tag"} /-->
<!-- wp:comments -->
<div class="wp-block-comments">
<!-- wp:comments-title /-->
<!-- wp:comment-template -->
<!-- wp:comment-author-name /-->
<!-- wp:comment-content /-->
<!-- /wp:comment-template -->
<!-- wp:post-comments-form /-->
</div>
<!-- /wp:comments -->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
templates/page.html for static pages:
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:post-title {"level":1} /-->
<!-- wp:post-content {"layout":{"type":"constrained"}} /-->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
templates/404.html:
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:heading {"level":1} -->
<h1 class="wp-block-heading">Page not found</h1>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p>The page you are looking for doesn't exist. Try searching instead.</p>
<!-- /wp:paragraph -->
<!-- wp:search {"label":"Search","buttonText":"Search"} /-->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
You can add archive.html and search.html in the same way, typically by copying index.html and adding a Query Title block (<!-- wp:query-title {"type":"archive"} /--> or {"type":"search"}) above the loop.
Registering Custom Templates
If you want users to choose special templates for individual pages, such as a full-width layout without a title, create the file (for example templates/page-no-title.html) and register it in theme.json:
"customTemplates": [
{
"name": "page-no-title",
"title": "Page Without Title",
"postTypes": [ "page" ]
}
]
Add customTemplates at the top level of theme.json, next to templateParts.
Step 7: Add a Block Pattern
Patterns give users ready-made sections. Create a patterns folder and add hero.php. WordPress registers any file here automatically based on its header:
<?php
/**
* Title: Hero
* Slug: sajjad-starter/hero
* Categories: banner
* Description: A centered hero section with heading, text, and button.
*/
?>
<!-- wp:group {"align":"full","backgroundColor":"surface","style":{"spacing":{"padding":{"top":"var:preset|spacing|70","bottom":"var:preset|spacing|70"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull has-surface-background-color has-background" style="padding-top:var(--wp--preset--spacing--70);padding-bottom:var(--wp--preset--spacing--70)">
<!-- wp:heading {"textAlign":"center","level":1} -->
<h1 class="wp-block-heading has-text-align-center"><?php echo esc_html__( 'Build something great', 'sajjad-starter' ); ?></h1>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center"} -->
<p class="has-text-align-center"><?php echo esc_html__( 'A short sentence that explains what you do.', 'sajjad-starter' ); ?></p>
<!-- /wp:paragraph -->
<!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} -->
<div class="wp-block-buttons">
<!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="#"><?php echo esc_html__( 'Get started', 'sajjad-starter' ); ?></a></div>
<!-- /wp:button -->
</div>
<!-- /wp:buttons -->
</div>
<!-- /wp:group -->
Because the file is PHP, you can make strings translatable with esc_html__(). The pattern now appears in the block inserter under the Banners category.
Step 8: Add functions.php
Block themes don't need functions.php, but it's the right place for theme setup and extra CSS. Create functions.php in the theme root:
<?php
/**
* Sajjad Starter theme functions.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Theme setup.
*/
function sajjad_starter_setup() {
// Load editor styles so the editor matches the front end.
add_theme_support( 'editor-styles' );
add_editor_style( 'assets/css/theme.css' );
}
add_action( 'after_setup_theme', 'sajjad_starter_setup' );
/**
* Enqueue front-end styles.
*/
function sajjad_starter_enqueue_styles() {
$theme = wp_get_theme();
wp_enqueue_style(
'sajjad-starter-style',
get_theme_file_uri( 'assets/css/theme.css' ),
array(),
$theme->get( 'Version' )
);
}
add_action( 'wp_enqueue_scripts', 'sajjad_starter_enqueue_styles' );
/**
* Register a custom block style for the Button block.
*/
function sajjad_starter_block_styles() {
register_block_style(
'core/button',
array(
'name' => 'pill',
'label' => __( 'Pill', 'sajjad-starter' ),
)
);
}
add_action( 'init', 'sajjad_starter_block_styles' );
Block themes automatically get support for features like post thumbnails, responsive embeds, and HTML5 markup, so you don't need to add those manually.
Then create assets/css/theme.css for any CSS that theme.json can't express:
.wp-block-button.is-style-pill .wp-block-button__link {
border-radius: 999px;
padding-inline: 1.75em;
}
.wp-block-post-title a {
text-decoration: none;
}
:where(.wp-block-post-template) > li + li {
margin-top: var(--wp--preset--spacing--50);
padding-top: var(--wp--preset--spacing--50);
border-top: 1px solid var(--wp--preset--color--surface);
}
Try to keep this file small. Most styling should live in theme.json, which keeps the editor and front end in sync.
Step 9: Add a Screenshot
WordPress displays screenshot.png in Appearance > Themes. The recommended size is 1200 by 900 pixels. Take a screenshot of your theme's home page and save it in the theme root.
Your final folder should look like this:
sajjad-starter/
├── assets/
│ └── css/
│ └── theme.css
├── parts/
│ ├── footer.html
│ └── header.html
├── patterns/
│ └── hero.php
├── templates/
│ ├── 404.html
│ ├── index.html
│ ├── page.html
│ └── single.html
├── functions.php
├── screenshot.png
├── style.css
└── theme.json
Step 10: Test Your Theme
Before using your theme on a real site, test it thoroughly:
- Import test content: The WordPress Theme Unit Test data (available from the Theme Review team) includes posts with every kind of formatting. Import it with Tools > Import > WordPress.
- Install Theme Check: The Theme Check plugin scans your theme for common problems and missing requirements.
- Test in the Site Editor: Open Appearance > Editor and edit templates, styles, and patterns to make sure everything works.
- Check accessibility: Test keyboard navigation, focus styles, color contrast, and heading order.
- Check different screen sizes: Make sure the navigation collapses correctly on mobile.
- Watch the debug log: Look at
wp-content/debug.logfor PHP notices or warnings.
Step 11: Package and Deploy
When you're happy with the theme:
- Export Site Editor changes: If you made changes in the Site Editor, they're stored in the database, not in your files. Use the Create Block Theme plugin to save them back to theme files, or copy the markup manually.
- Zip the folder: Compress the
sajjad-starterfolder intosajjad-starter.zip. - Upload: On your live site (after a backup), go to Appearance > Themes > Add New Theme > Upload Theme, choose the zip, and activate it.
If you use WP-CLI, you can install and activate the zip in one command:
wp theme install sajjad-starter.zip --activate
Building a Classic Theme Instead
Some projects still need a classic PHP theme, for example when maintaining an older site or integrating with plugins that depend on widget areas. A minimal classic theme needs style.css and index.php:
<?php
/**
* Main template file.
*/
?>
<!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>
<a href="<?php echo esc_url( home_url( '/' ) ); ?>"><?php bloginfo( 'name' ); ?></a>
<?php
wp_nav_menu(
array(
'theme_location' => 'primary',
'fallback_cb' => false,
)
);
?>
</header>
<main>
<?php if ( have_posts() ) : ?>
<?php while ( have_posts() ) : the_post(); ?>
<article <?php post_class(); ?>>
<h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
<?php the_excerpt(); ?>
</article>
<?php endwhile; ?>
<?php the_posts_pagination(); ?>
<?php else : ?>
<p><?php esc_html_e( 'Nothing found.', 'sajjad-classic' ); ?></p>
<?php endif; ?>
</main>
<?php wp_footer(); ?>
</body>
</html>
And a functions.php to register theme features and menus:
<?php
function sajjad_classic_setup() {
add_theme_support( 'title-tag' );
add_theme_support( 'post-thumbnails' );
add_theme_support( 'html5', array( 'search-form', 'comment-form', 'comment-list', 'gallery', 'caption', 'style', 'script' ) );
add_theme_support( 'custom-logo' );
register_nav_menus(
array(
'primary' => __( 'Primary Menu', 'sajjad-classic' ),
)
);
}
add_action( 'after_setup_theme', 'sajjad_classic_setup' );
function sajjad_classic_scripts() {
wp_enqueue_style( 'sajjad-classic-style', get_stylesheet_uri(), array(), wp_get_theme()->get( 'Version' ) );
}
add_action( 'wp_enqueue_scripts', 'sajjad_classic_scripts' );
From there, you'd split the markup into header.php, footer.php, single.php, and other templates, loading them with get_header() and get_footer(). The calls to wp_head(), wp_body_open(), and wp_footer() are essential, because plugins rely on them to add scripts and styles.
Best Practices for Custom Themes
Keep functionality out of the theme: Things like custom post types, shortcodes, and analytics belong in a plugin, so they survive a theme change.
Use theme.json for design: Define colors, fonts, and spacing there rather than hard-coding CSS values.
Escape all output: In PHP templates and patterns, use esc_html(), esc_url(), and esc_attr() whenever you print data.
Prefix everything: Prefix function names, handles, and pattern slugs with your theme slug to avoid conflicts.
Make it translatable: Wrap user-facing strings in translation functions with your text domain.
Use version control: Keep your theme in Git so you can track changes and roll back.
FAQ: Creating a Custom WordPress Theme
A block theme needs style.css and templates/index.html. A classic theme needs style.css and index.php. Most themes also include theme.json, functions.php, and a screenshot.
For new projects in 2026, a block theme is usually the better choice because it works with the Site Editor and modern WordPress features. Classic themes still make sense for maintaining older sites.
Not much for a block theme. You mainly need HTML, CSS, and JSON. PHP is only required for functions.php, patterns with translatable text, or classic themes.
A simple block theme can be built in a day or two. A polished theme with many templates, patterns, and style variations can take weeks, depending on the design and testing required.
Yes. The Create Block Theme plugin can generate a blank block theme or clone an existing one, and many developers start from a default theme like Twenty Twenty-Five.
Site Editor changes are saved to the database, not to your theme folder. Use the Create Block Theme plugin or export the theme from the Site Editor to write those changes back into files.
Yes. WordPress themes are typically released under the GPL license. If you submit a theme to the WordPress.org directory, it must pass the theme review requirements.
Conclusion
Creating a custom WordPress theme from scratch is more approachable than ever. With a block theme, you need only a style.css file, a theme.json file, and a handful of HTML templates and template parts to build a fully working, fully editable theme. From there, patterns, custom templates, and a small functions.php let you shape it into exactly what your project needs.
Start small: get the index template, header, and footer working, then add templates and patterns one at a time. Test everything on a local site, keep your code in version control, and move site functionality into plugins. Once you've built one theme this way, you'll have a reusable starter you can adapt for every future project.


