Classic Theme to Full Site Editing: A Production Migration

WordPress PHP 8 Gutenberg Interactivity API Block Theme theme.json

The Project

PausaTerapia is a Spanish-language psychology blog with 60+ published articles, psychological tests, and a companion app landing page. The site is owned and managed by a psychologist with no technical
background, so every editorial workflow needed to be intuitive through the WordPress block editor.

I rebuilt the theme from scratch: from a classic PHP theme dependent on Tailwind CDN to a native Full Site Editing block theme powered entirely by theme.json and custom Gutenberg blocks. The result is a
production site the owner maintains independently, creating and publishing content without touching code.

Why This Migration

The original theme loaded Tailwind via CDN, used classic PHP template files (single.php, page.php), rendered navigation with wp_nav_menu(), and pulled Google Fonts through an external <link> tag. It worked, but it was a classic WordPress theme dressed in utility classes, not a block theme.

The goal was to eliminate every external dependency and move the entire design system into WordPress-native APIs. No CSS framework, no external font requests, no jQuery on the frontend. Every layout decision lives in theme.json, every interactive behavior runs through the Interactivity API.

Architecture Decisions

theme.json as the Single Source of Truth

The design system is defined entirely in theme.json v3: color palette, font families with self-hosted fontFace declarations, fluid typography using clamp() via the fluid property, layout constraints, and element-level styles for headings and links.

Fonts are self-hosted as .woff2 files referenced through file:./assets/fonts/ paths in the fontFace config. This removes the external Google Fonts request and gives full control over font loading with fontDisplay: swap.

Fluid typography scales text smoothly between viewports. Small body sizes stay fixed; heading sizes from lg upward interpolate between a min and max value. WordPress generates the clamp() functions automatically from the theme.json declaration.

Block Templates Replace PHP Templates

The entire template hierarchy (home.html, single.html, page.html, archive.html, search.html, 404.html, front-page.html, single-psico_test.html) is built with block markup. Template parts for header and footer are registered in theme.json and referenced across templates.

There are no PHP template files. The header uses a wp:navigation block with overlayMenu: mobile for responsive behavior. The footer is structured with wp:columns and hardcoded wp:navigation-link blocks for controlled link groups.

Custom Blocks with block.json, edit.js, and render.php

Five custom blocks, each registered via block.json metadata:

pausa/category-filter: The most complex block. Renders an interactive category tab bar, post grid, and pagination. The initial page is server-rendered in render.php for SEO (search engines see real HTML, not a loading spinner). Client-side interactions use the Interactivity API.

pausa/share-buttons: A copy-to-clipboard button that uses the Interactivity API store for state management. Each button instance has its own context (copied: true/false), and the icon swap (copy/check) is handled via CSS class toggling (data-wp-class--share-btn--copied) rather than DOM manipulation.

pausa/breadcrumbs: Dynamic server-rendered breadcrumbs that handle posts, pages, custom post types (psico_test), categories, search results, and 404 pages.

pausa/related-posts: A WP_Query selecting same-category posts, excluding the current one, ordered randomly.

pausa/post-meta: Reading time calculation and category color badge, combining data from helper functions into a single block.

Interactivity API for Client-Side Behavior

The category filter block is where this gets interesting. It uses wp_interactivity_state() to hydrate the initial state on the server, then a client-side store manages activeCategory, currentPage, totalPages, and isLoading.

Category tab clicks trigger a generator action that fetches from a custom REST endpoint (pausa/v1/posts). The endpoint returns server-rendered HTML fragments (not JSON data), so the post card markup is consistent between initial load and subsequent pages.

One real problem I solved: the Interactivity API binds directives at initialization, but pagination buttons are injected dynamically after each fetch (they change based on the current page and total). Directives like data-wp-on--click don’t bind on innerHTML-injected elements. The solution was event delegation: a single document.addEventListener('click') that checks for [data-page] attributes, keeping the Interactivity API store as the state manager while handling click binding outside of it.

Custom REST Endpoint

The pausa/v1/posts endpoint accepts category, page, and per_page parameters. It runs a WP_Query and calls the same pausa_render_post_card() function used in the server-side render, so there’s a single source of truth for card HTML. The response includes the rendered HTML string and totalPages for pagination.

Block Patterns

Four patterns registered under custom categories (pausa-heroes, pausa-cta, pausa-content): two hero variants, a CTA banner, and a feature grid. Each is a PHP file in /patterns with the standard file header comment format.

Build Pipeline and Deployment

The block JavaScript is compiled with wp-scripts build using the --experimental-modules flag for viewScriptModule (required for Interactivity API ES modules). Asset versioning uses filemtime() instead of manual version bumps, so CSS and JS cache-bust automatically on every change.

Deployment runs through GitHub Actions: push to main triggers a build that compiles the block assets and deploys to production on AWS. The theme and plugin repos deploy independently.

The Plugin: Psychological Tests

A separate plugin (psico-tests) registers a custom post type (psico_test) with its own block (psico-tests/test-embed), REST API endpoints, and a dedicated single template (single-psico_test.html). It demonstrates that the block theme architecture extends cleanly to plugin-registered content types.

Technical Stack

  • PHP 8.0+ with strict types, WordPress Coding Standards (PHPCS), static analysis (PHPStan level 6)
  • theme.json v3 with fluid typography, self-hosted fontFace, design tokens
  • Custom Gutenberg blocks: block.json metadata, React edit components with InspectorControls, PHP render callbacks
  • WordPress Interactivity API: reactive stores, server-side state hydration, viewScriptModule
  • Custom REST endpoints returning server-rendered HTML
  • wp-scripts build pipeline with ES module support
  • Docker local environment, GitHub Actions CI/CD, AWS production
  • Zero external CSS frameworks, zero jQuery on frontend

What I Would Do Differently

If I were building this for a newsroom at scale, the REST endpoint would use fragment caching with transients and cache invalidation on save_post. The current implementation queries on every request, which is fine for this traffic level but wouldn’t survive high concurrency.

I’d also split the monolithic blocks.css (1700+ lines) into per-block stylesheets loaded via wp_enqueue_block_style(), so each page only loads the CSS for the blocks it actually renders.

The category filter’s event delegation workaround, while functional, is a pragmatic solution. At scale, I’d explore whether a Web Component wrapper or a more granular Interactivity API directive structure could handle dynamic content binding natively.