Chapter 12 — Front-End Fundamentals

WordPress Theme Structure

You already have a working web page. It has structure, styles, and JavaScript. You built it yourself across the last eleven chapters. That’s the starting point for this chapter, not a blank slate.

What you’re going to do is take that static HTML page and convert it, step by step, into a WordPress theme. By the end, WordPress will be serving it, the Loop will be populating the content area with real WordPress content, your Sass and JavaScript will be properly enqueued, and you’ll have a set of template files covering the most common content types. The page won’t look different. The mechanism behind it will be completely different.

Before any of that, you need WordPress running locally.


Build out the static page first

The index.html you built in earlier chapters is minimal: a nav, a heading, a paragraph, and a footer. Before converting it to WordPress, flesh it out a little so the conversion has something real to work with. Open src/index.html in VS Code and update the body to this:

html~/tutorials/
<body>
  <header class="site-header">
    <div class="wrap">
      <a href="/" class="site-title">Tutorials</a>
      <nav class="site-nav" aria-label="Primary navigation">
        <ul>
          <li><a href="/">Home</a></li>
          <li><a href="/about.html">About</a></li>
        </ul>
      </nav>
    </div>
  </header>

  <main id="main-content" class="site-main">
    <div class="wrap">
      <article>
        <h1 class="page-title">Getting started with front-end development</h1>
        <div class="entry-content">
          <p>
            This is a project built as part of a tutorial series covering the
            basics of front-end development. By the end, you'll have built a
            working WordPress theme from scratch.
          </p>
          <p>
            Each chapter assumes you've completed the previous one. If something
            here isn't making sense, go back to the chapter that introduced it.
          </p>
        </div>
      </article>
    </div>
  </main>

  <footer class="site-footer">
    <div class="wrap">
      <p class="footer-copy">&copy; 2026 Your Name</p>
    </div>
  </footer>

  <script src="js/main.js"></script>
</body>

Update your Sass partials to match the new class names. The structure of _base.scss, _nav.scss, and _footer.scss can stay mostly the same. The selectors just need to target the new classes where they’ve changed. Run gulp and confirm the page still looks right in the browser before moving on.

This matters because you’re about to convert this exact HTML into WordPress templates. The more recognizable it is, the clearer the conversion will be.


Get WordPress running locally

The tool for running WordPress on your machine is LocalWP. It handles PHP, MySQL, and a local web server in a single application.

On macOS: Download the macOS installer from localwp.com, open it, and drag LocalWP to Applications.

On Ubuntu: Download the .deb package from localwp.com, then install it:

bash~/tutorials/
sudo dpkg -i local-*.deb

Launch LocalWP, click the + button, and create a new site:

  • Site name: tutorials
  • Environment: Preferred
  • WordPress username and password: set something you’ll remember

When it’s done, click Open site to confirm WordPress loads in a browser. You’ll see the default theme.

Your site files live at a path LocalWP shows you in the sidebar. The themes directory is inside it:

plaintext~/tutorials/
~/Local Sites/tutorials/app/public/wp-content/themes/

Navigate there in your terminal:

bash~/tutorials/
cd ~/Local\ Sites/tutorials/app/public/wp-content/themes

Create the theme and activate it

Create the theme folder and the two files WordPress requires to recognize it:

bash~/tutorials/
mkdir tutorials-theme
cd tutorials-theme
touch style.css
touch index.php

Open style.css and add the theme header comment. WordPress reads this to identify the theme:

css~/tutorials/
/*
Theme Name: Tutorials
Theme URI: https://example.com
Author: Your Name
Author URI: https://example.com
Description: A simple, modern WordPress theme built from scratch.
Version: 1.0.0
License: GNU General Public License v2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html
Text Domain: tutorials-theme
*/

The format is exact. Each field is Field Name: Value. WordPress parses this comment to populate the Themes screen in the admin. Leave the rest of style.css empty for now. Styles will be enqueued properly through functions.php later in this chapter.

Leave index.php empty for the moment too.

Go to the WordPress admin dashboard. In LocalWP, click WP Admin. Navigate to Appearance > Themes. You should see “Tutorials” listed. Activate it.

Visit the site frontend. You’ll see a blank white page. That’s correct. index.php is empty, so WordPress finds the template, renders it, and outputs nothing. The theme is active and working. It just has nothing to say yet.


Copy your HTML into index.php

Open index.php and paste the entire contents of src/index.html into it. The full file, from <!DOCTYPE html> to </html>. PHP files can contain plain HTML: a .php file with no PHP code in it is valid and outputs its HTML exactly as written.

Save index.php and reload the site in the browser.

The page content is there. The nav, the heading, the paragraphs, the footer: all of it is rendering. But the styles are gone. The button no longer toggles anything. The page looks like raw, unstyled HTML from 1996.

This is expected. Here’s why.

When you opened index.html directly in a browser from your filesystem, the browser resolved href="css/main.css" relative to the file’s location on disk. It found the file. When WordPress serves the page, it’s served from a URL like http://tutorials.local/. The browser tries to load http://tutorials.local/css/main.css. That path doesn’t exist. The stylesheet is sitting in your tutorials project directory on your filesystem, not inside the WordPress theme.

This is the first place where “it worked before” meets “WordPress does things differently.” The solution is functions.php and WordPress’s asset enqueueing system. You’ll get there. First, sort out the template structure.


Cut index.php into parts

A WordPress theme assembles pages from multiple template files. header.php contains the <head> and the opening of the <body>. footer.php closes it out. index.php, and every other template file, calls get_header() and get_footer() to include them.

This pattern means your header and footer HTML live in one place. Change header.php and every template on the site reflects it.

Create the two files:

bash~/tutorials/
touch header.php
touch footer.php

Cut from index.php, paste into header.php:

Everything from <!DOCTYPE html> down through and including the closing </header> tag:

php~/tutorials/
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Tutorials</title>
    <link rel="stylesheet" href="css/main.css" />
  </head>
  <body>

  <header class="site-header">
    <div class="wrap">
      <a href="/" class="site-title">Tutorials</a>
      <nav class="site-nav" aria-label="Primary navigation">
        <ul>
          <li><a href="/">Home</a></li>
          <li><a href="/about.html">About</a></li>
        </ul>
      </nav>
    </div>
  </header>

Cut from index.php, paste into footer.php:

Everything from the opening <footer> tag down through </html>:

php~/tutorials/
  <footer class="site-footer">
    <div class="wrap">
      <p class="footer-copy">&copy; 2026 Your Name</p>
    </div>
  </footer>

  <script src="js/main.js"></script>
</body>
</html>

What’s left in index.php is just the <main> block. Add get_header() and get_footer() around it:

php~/tutorials/
<?php get_header(); ?>

<main id="main-content" class="site-main">
  <div class="wrap">
    <article>
      <h1 class="page-title">Getting started with front-end development</h1>
      <div class="entry-content">
        <p>
          This is a project built as part of a tutorial series covering the
          basics of front-end development. By the end, you'll have built a
          working WordPress theme from scratch.
        </p>
        <p>
          Each chapter assumes you've completed the previous one. If something
          here isn't making sense, go back to the chapter that introduced it.
        </p>
      </div>
    </article>
  </div>
</main>

<?php get_footer(); ?>

Reload the browser. The page looks exactly the same as before the cut. get_header() includes header.php and get_footer() includes footer.php. WordPress assembles the three files into one complete HTML document. The output is identical. The structure is now maintainable.


Replace static content with the Loop

Right now the content area of index.php is hardcoded HTML. That text will appear on every page of the site, regardless of what WordPress post or page is being viewed. You need to replace it with the Loop.

The Loop is the pattern WordPress uses to display content. It checks whether there are posts to display, iterates through them, and outputs the data for each one. Replace the hardcoded <article> block with this:

php~/tutorials/
<?php get_header(); ?>

<main id="main-content" class="site-main">
  <div class="wrap">

    <?php if ( have_posts() ) : ?>

      <?php while ( have_posts() ) : the_post(); ?>

        <article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
          <h1 class="page-title"><?php the_title(); ?></h1>
          <div class="entry-content">
            <?php the_content(); ?>
          </div>
        </article>

      <?php endwhile; ?>

    <?php else : ?>

      <p>No content found.</p>

    <?php endif; ?>

  </div>
</main>

<?php get_footer(); ?>

have_posts() returns true if WordPress found posts to display for the current request. the_post() advances the internal pointer to the next post and loads its data. the_title() outputs the post title. the_content() outputs the post body. the_ID() outputs the post’s numeric ID. post_class() outputs a set of CSS classes WordPress generates for the post.

The alternative syntax (if () : / endif;, while () : / endwhile;) is the WordPress Coding Standards preference for template files. PHPCS will flag curly braces here. Use the colon form in all template files.

Reload the browser. WordPress is now serving real content. Go to the WordPress admin, create a page, publish it, and set it as the static front page under Settings > Reading. Reload. The page title and content you entered in the editor now appear in the template.


Introduce functions.php

functions.php is where you configure how the theme interacts with WordPress. It’s loaded automatically on every request. You use it to tell WordPress what the theme supports, register navigation menus, and enqueue your CSS and JavaScript files.

bash~/tutorials/
mkdir inc
touch functions.php
touch inc/setup.php

functions.php stays lean. The actual setup logic goes in inc/setup.php, which functions.php loads. This keeps things organized as the theme grows.

functions.php:

php~/tutorials/
<?php
/**
 * Tutorials theme functions.
 *
 * @package tutorials-theme
 */

require get_template_directory() . '/inc/setup.php';

get_template_directory() returns the absolute filesystem path to the active theme’s directory. This is how you reference files within your theme without hardcoding paths.

inc/setup.php:

php~/tutorials/
<?php
/**
 * Theme setup.
 *
 * @package tutorials-theme
 */

/**
 * Theme setup: support features and register nav menus.
 */
function tutorials_setup() {
	add_theme_support( 'title-tag' );
	add_theme_support( 'post-thumbnails' );
	add_theme_support( 'html5', array( 'search-form', 'comment-form', 'gallery', 'caption' ) );

	register_nav_menus(
		array(
			'primary' => esc_html__( 'Primary Navigation', 'tutorials-theme' ),
		)
	);
}
add_action( 'after_setup_theme', 'tutorials_setup' );

/**
 * Enqueue theme styles and scripts.
 */
function tutorials_enqueue_assets() {
	wp_enqueue_style(
		'tutorials-styles',
		get_template_directory_uri() . '/assets/css/main.css',
		array(),
		wp_get_theme()->get( 'Version' )
	);

	wp_enqueue_script(
		'tutorials-scripts',
		get_template_directory_uri() . '/assets/js/main.js',
		array(),
		wp_get_theme()->get( 'Version' ),
		true
	);
}
add_action( 'wp_enqueue_scripts', 'tutorials_enqueue_assets' );

add_theme_support( 'title-tag' ) hands control of the <title> element to WordPress, which generates the correct title for each page automatically. add_theme_support( 'post-thumbnails' ) enables featured images. The 'html5' array tells WordPress to output semantic HTML5 markup for those specific elements.

register_nav_menus() registers a navigation location. The key 'primary' is the location identifier you’ll reference in the template. The value is the label that appears in Appearance > Menus in the admin.

add_action() is WordPress’s hook system. add_action( 'hook_name', 'function_name' ) tells WordPress to call your function when that hook fires. Chapter 14 covers hooks in depth.

wp_enqueue_style() registers and loads a stylesheet. The arguments are: a unique handle, the URL to the file, an array of dependencies, and a version string. get_template_directory_uri() returns the URL to the theme directory. wp_get_theme()->get( 'Version' ) reads the version from your style.css header for cache busting.

wp_enqueue_script() does the same for JavaScript. The fifth argument true loads the script in the footer.

The naming convention matters. Prefix all functions with a unique identifier (tutorials_) to avoid collisions with WordPress core or plugin functions. PHPCS will flag unprefixed functions.


Update header.php to use WordPress functions

php~/tutorials/
<!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(); ?>

  <a class="skip-link screen-reader-text" href="#main-content">
    <?php esc_html_e( 'Skip to content', 'tutorials-theme' ); ?>
  </a>

  <header class="site-header">
    <div class="wrap">
      <a href="<?php echo esc_url( home_url( '/' ) ); ?>" class="site-title" rel="home">
        <?php bloginfo( 'name' ); ?>
      </a>
      <nav class="site-nav" aria-label="<?php esc_attr_e( 'Primary navigation', 'tutorials-theme' ); ?>">
        <?php
        wp_nav_menu(
          array(
            'theme_location' => 'primary',
            'menu_class'     => 'nav-list',
            'container'      => false,
          )
        );
        ?>
      </nav>
    </div>
  </header>

language_attributes() outputs the correct lang attribute for <html> based on WordPress settings. bloginfo( 'charset' ) reads the character set from WordPress. wp_head() is required immediately before </head>. It’s how WordPress and plugins inject styles, scripts, and meta tags. Without it, your enqueued stylesheet doesn’t load. body_class() outputs per-request CSS classes on <body>. wp_body_open() fires a hook immediately after <body> that some plugins depend on.

esc_url(), esc_html_e(), esc_attr_e() are escaping functions. They sanitize dynamic values before outputting them to the browser. PHPCS will flag any dynamic output that isn’t wrapped in an appropriate escaping function. home_url( '/' ) returns the site’s home URL. Never hardcode a URL in a theme.

wp_nav_menu() outputs the menu assigned to the 'primary' location in Appearance > Menus.


Update footer.php to use WordPress functions

php~/tutorials/
  <footer class="site-footer">
    <div class="wrap">
      <p class="footer-copy">
        &copy; <?php echo esc_html( date( 'Y' ) ); ?>
        <?php bloginfo( 'name' ); ?>
      </p>
    </div>
  </footer>

  <?php wp_footer(); ?>

</body>
</html>

wp_footer() is required immediately before </body>. It is to the footer what wp_head() is to the head. Your enqueued JavaScript loads here. Without it, the script you enqueued in functions.php never appears in the HTML output.


Copy your compiled assets into the theme

bash~/tutorials/
mkdir -p assets/css assets/js
cp ~/tutorials/dist/css/main.css assets/css/main.css
cp ~/tutorials/dist/js/main.js assets/js/main.js

Reload the browser. Your styles are back. WordPress is serving the page, assembling it from three template files, pulling content from the database, and loading your compiled CSS and JS through the enqueueing system.

This is a working WordPress theme.


Add the remaining template files

With the core working, build out the standard template set. Each file handles a different type of request in the template hierarchy.

page.php (static pages):

php~/tutorials/
<?php get_header(); ?>

<main id="main-content" class="site-main">
  <div class="wrap">

    <?php while ( have_posts() ) : the_post(); ?>

      <article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
        <h1 class="page-title"><?php the_title(); ?></h1>
        <div class="entry-content">
          <?php the_content(); ?>
        </div>
      </article>

    <?php endwhile; ?>

  </div>
</main>

<?php get_footer(); ?>

single.php (individual blog posts):

php~/tutorials/
<?php get_header(); ?>

<main id="main-content" class="site-main">
  <div class="wrap">

    <?php while ( have_posts() ) : the_post(); ?>

      <article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>

        <header class="entry-header">
          <h1 class="entry-title"><?php the_title(); ?></h1>
          <div class="entry-meta">
            <time datetime="<?php echo esc_attr( get_the_date( 'c' ) ); ?>">
              <?php echo esc_html( get_the_date() ); ?>
            </time>
            <span class="byline"><?php the_author(); ?></span>
          </div>
        </header>

        <div class="entry-content">
          <?php the_content(); ?>
        </div>

        <footer class="entry-footer">
          <?php the_tags( '<div class="tags-links">', ', ', '</div>' ); ?>
        </footer>

      </article>

    <?php endwhile; ?>

  </div>
</main>

<?php get_footer(); ?>

archive.php (category, tag, date, and author archives):

php~/tutorials/
<?php get_header(); ?>

<main id="main-content" class="site-main">
  <div class="wrap">

    <header class="archive-header">
      <h1 class="archive-title"><?php the_archive_title(); ?></h1>
      <?php the_archive_description( '<div class="archive-description">', '</div>' ); ?>
    </header>

    <?php if ( have_posts() ) : ?>

      <div class="post-list">
        <?php while ( have_posts() ) : the_post(); ?>

          <article id="post-<?php the_ID(); ?>" <?php post_class( 'post-card' ); ?>>
            <h2 class="entry-title">
              <a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
            </h2>
            <div class="entry-meta">
              <time datetime="<?php echo esc_attr( get_the_date( 'c' ) ); ?>">
                <?php echo esc_html( get_the_date() ); ?>
              </time>
            </div>
            <div class="entry-summary">
              <?php the_excerpt(); ?>
            </div>
          </article>

        <?php endwhile; ?>
      </div>

    <?php else : ?>
      <p><?php esc_html_e( 'No posts found.', 'tutorials-theme' ); ?></p>
    <?php endif; ?>

  </div>
</main>

<?php get_footer(); ?>

404.php (page not found):

php~/tutorials/
<?php get_header(); ?>

<main id="main-content" class="site-main">
  <div class="wrap">
    <article class="error-404">
      <header class="entry-header">
        <h1 class="page-title">
          <?php esc_html_e( 'Page not found.', 'tutorials-theme' ); ?>
        </h1>
      </header>
      <div class="entry-content">
        <p>
          <?php esc_html_e( "The page you're looking for doesn't exist or has been moved.", 'tutorials-theme' ); ?>
        </p>
        <a href="<?php echo esc_url( home_url( '/' ) ); ?>">
          <?php esc_html_e( 'Go home', 'tutorials-theme' ); ?>
        </a>
      </div>
    </article>
  </div>
</main>

<?php get_footer(); ?>

Set up the build pipeline in the theme

Copy the following from the original tutorials project into the theme root:

  • gulpfile.js
  • package.json
  • package-lock.json
  • .gitignore
  • eslint.config.js
  • .stylelintrc.json
  • phpcs.xml

Copy the source directories:

bash~/tutorials/
cp -r ~/tutorials/src/scss src/scss
cp -r ~/tutorials/src/js src/js

Update the output paths in gulpfile.js:

js~/tutorials/
function css() {
  return src('src/scss/main.scss')
    .pipe(sass({ outputStyle: 'expanded' }).on('error', sass.logError))
    .pipe(dest('assets/css/'));
}

async function js() {
  const bundle = await rollup.rollup({
    input: 'src/js/main.js',
    plugins: [nodeResolve()],
  });
  await bundle.write({
    file: 'assets/js/main.js',
    format: 'iife',
    name: 'App',
    sourcemap: true,
  });
}

Update serve() to proxy LocalWP:

js~/tutorials/
function serve() {
  browserSync.init({
    proxy: 'tutorials.local', // match your LocalWP domain
    files: ['assets/css/**/*.css', 'assets/js/**/*.js', '**/*.php'],
    notify: false,
  });

  watch('src/scss/**/*.scss', series(css, reload));
  watch('src/js/**/*.js', series(js, reload));
}

Remove the html task entirely. WordPress generates HTML from PHP templates now.

Update phpcs.xml to lint the theme PHP files:

xml~/tutorials/
<file>.</file>
<exclude-pattern>*/node_modules/*</exclude-pattern>
<exclude-pattern>*/vendor/*</exclude-pattern>
<exclude-pattern>*/assets/*</exclude-pattern>

Install and run:

bash~/tutorials/
npm install
npm run build
npm run start

Initialize Git in the theme

bash~/tutorials/
git init

.gitignore:

plaintext~/tutorials/
.DS_Store
node_modules/
assets/css/
assets/js/
*.log
bash~/tutorials/
git add .
git commit -m "Initial theme: templates, functions.php, and Gulp pipeline"

What you now know

You took a static HTML page, activated it as a bare WordPress theme, cut it into template parts, wired the content area to the Loop, enqueued your assets through functions.php, and built out the full set of standard template files. WordPress is serving real content through templates you wrote from scratch.

In Chapter 13 you’ll deepen the templating: reusable template parts, conditional tags, the front-page/home.php distinction, and custom page templates.

Reference