Creating a Theme

A jekcms theme is a folder of PHP and CSS. No build step, no framework, no generator - the router requires your template and whatever it echoes is the page.

The Themes screen, which draws its list from the registered theme set
A folder that is not in the registry does not appear here, however correct its theme.json is.

Before you start, one limitation to weigh. The Themes screen renders from a fixed registry of known theme slugs. A new folder in /themes/ is not picked up by the admin panel, and there is no ZIP upload and no way to register a slug from the UI, so a theme you write yourself cannot be offered or activated through Appearance → Themes. New themes reach an install through the signed update channel instead.

That makes this page a description of how the theme layer works - useful for reading the shipped themes, for editing a copy in a development install, and for anyone building a theme to be distributed with the product - rather than a route to a theme you can install on a customer site this afternoon.

Anatomy

themes/my-theme/
  theme.json          manifest and customizer schema
  functions.php       loaded automatically on every request
  activate.php        optional, runs once when the theme is activated
  screenshot.png      card image
  templates/
    header.php  footer.php
    index.php   single.php  page.php  archive.php  search.php
    errors/404.php
  partials/
    post-card.php  pagination.php  sidebar.php  comments.php
  sections/
    hero.php  latest.php
  assets/
    css/style.css

Only templates/index.php is genuinely required - it is what the activation check parses. Everything else is convention that the router and the layout engine rely on.

The manifest

{
    "name": "Ocean",
    "slug": "ocean",
    "version": "1.0.0",
    "author": "Your Name",
    "description": "A clean coastal theme.",
    "screenshot": "screenshot.png",
    "supports": { "card_options": true, "share_styles": true }
}

The slug the system uses is always the folder name; the slug key in the file is documentation, never read. See the theme.json reference for the keys that actually do something.

How the router picks a template

Templates live under templates/, and the router requires them by name against the active theme: index.php for the homepage, single.php for a post, page.php for a page, archive.php for category, tag, author and date archives, search.php for search results. Error pages resolve as templates/{code}.php first and templates/errors/{code}.php second, so 404.php can live in either place.

header.php and footer.php are not router-resolved. Templates include them themselves, which is why every shipped template starts and ends the same way:

<?php
require_once dirname(__DIR__) . '/functions.php';
include __DIR__ . '/header.php';
// …
include __DIR__ . '/footer.php';

What is in scope

The router publishes very little, and it publishes it through $GLOBALS rather than through local variables. On a post or page, $GLOBALS['post'] holds the full row joined with the author's name, slug and avatar. On an archive, $GLOBALS['archive_type'] and $GLOBALS['archive_slug'] say which archive you are on, with archive_year and archive_month on date archives.

On the homepage, nothing is injected at all. templates/index.php queries for what it needs. There is no $GLOBALS['posts'] waiting for you - every shipped theme builds its own list, which is why archive templates open with their own SELECT.

<?php
require_once dirname(__DIR__) . '/functions.php';

$post = $GLOBALS['post'] ?? null;
if (!$post) { http_response_code(404); /* … */ return; }

include __DIR__ . '/header.php';
?>
<article class="post">
    <h1><?= e($post['title']) ?></h1>
    <div class="post-content"><?= render_content($post['content']) ?></div>
</article>
<?php include __DIR__ . '/footer.php'; ?>

Use e() for escaping - it is the project's short form of htmlspecialchars.

The head, the part that matters

A theme's header.php is where most of the product plugs into the page. The shipped set calls, in order: get_meta_title() for the <title>, output_robots_meta() for the indexing directives, output_canonical_tag(), output_theme_custom_fonts() for the fonts the customizer selected, theme_asset() for the stylesheet, output_theme_customization_css() for the generated CSS variables, then output_og_tags(), output_schema(), output_favicon_tags() and output_seo_head().

Skip output_theme_customization_css() and the entire customizer stops reaching the page - every colour, font, width and studio setting is emitted from that one call. Skip output_robots_meta() and post-level indexing rules silently stop working.

Content is rendered with the theme's own render_content(), not by printing $post['content']: that is where shortcodes are resolved, in-content boxes are injected and lazy-loading is applied.

functions.php

The active theme's functions.php is loaded automatically on every request, and templates also require it themselves so a template included out of band still has it. Three constants are defined by then - THEME_PATH, THEME_URL and THEME_VERSION.

Each theme defines its own small set of local helpers in this file rather than inheriting them: theme_asset() (prefixes assets/ and appends a filemtime cache buster), theme_config() and theme_setting() (read the static settings block of theme.json), theme_supports(), render_content(), render_pagination() and get_body_classes(). If you copy a theme, copy its functions.php with it - these are not core functions.

Partials and sections

partials/ holds the pieces you reuse: a post card, pagination, a sidebar, the comment block. Include them directly; a partial inherits the caller's scope, so $post inside a foreach is visible without being passed.

sections/ is different. Those files are the homepage blocks the customizer lets people reorder and switch off, rendered by the layout engine rather than included in a fixed order. A theme's index.php asks the engine to draw the page and falls back to a default sequence only if the engine is unavailable.

One rule matters more than the rest here, because breaking it produces a bug that is nearly impossible to find: an included file runs in the caller's scope. A partial that assigns to $post, $posts, $categories, $tags or $counts silently overwrites the calling template's variable. Prefix anything a partial creates.

Hooks

do_action('wp_head') and do_action('wp_footer') exist and work - core registers listeners on wp_footer for the integrity watermark and the analytics trackers. They are optional, and most shipped themes do not call them; three do. If your theme is meant to host third-party snippets, call both.

Note that apply_filters() exists as a stub and returns its input unchanged. Do not build on it.

Next

Once your theme renders, give it a customizer schema so its colours, fonts and layout become editable without touching files: Theme customization explains the mechanism, and the theme.json schema lists every key.

Be the first to know

New features, release notes and CMS guides. We send a couple of emails a month.