A clean WordPress theme can start from an Astro project.
Not because Astro runs in WordPress. Because Astro is the right tool for building UI, and plain PHP partials are the right format for WordPress to consume.
The gap between the two is a mechanical translation. This post covers how to do it cleanly.
What “clean” means in this context
A clean PHP template for WordPress:
- has one responsibility — rendering a data structure
- receives variables as a plain PHP array
- never fetches its own data
- uses WordPress escaping functions consistently
- matches the HTML structure of the Astro component exactly
<?php
// partials/card.php — generated by FM Solo
// Expects: $fm['props']['title'], $fm['props']['excerpt'], $fm['props']['url'], $fm['props']['image']
?>
<article class="card">
<a href="<?= esc_url($fm['props']['url']) ?>">
<img src="<?= esc_url($fm['props']['image']) ?>" alt="<?= esc_attr($fm['props']['title']) ?>" />
<h2><?= esc_html($fm['props']['title']) ?></h2>
<p><?= esc_html($fm['props']['excerpt']) ?></p>
</a>
</article>
No WordPress API calls inside the partial. No get_the_title(). No the_post(). Just variables in, HTML out.
The variable passing convention
Every partial receives a single array named after the component:
// You write this — Solo generates the partial, not the data wiring
$fm = [
'props' => [
'title' => get_the_title(),
'excerpt' => get_the_excerpt(),
'url' => get_permalink(),
'image' => get_the_post_thumbnail_url(),
]
];
include get_template_directory() . '/partials/card.php';
The page template is responsible for fetching data from WordPress and passing it to the partial. The partial is responsible for rendering it. Never mix the two.
This pattern maps directly to Astro’s component model — the page fetches, the component renders.
Handling optional fields
In Astro, optional props are marked with ?. In PHP, they translate to null checks:
export interface Props {
title: string; // required
excerpt?: string; // optional
badge?: string; // optional
}
<?php // partials/card.php ?>
<article class="card">
<?php if (!empty($card['badge'])): ?>
<span class="badge"><?= esc_html($card['badge']) ?></span>
<?php endif; ?>
<h2><?= esc_html($fm['props']['title']) ?></h2>
<?php if (!empty($fm['props']['excerpt'])): ?>
<p><?= esc_html($fm['props']['excerpt']) ?></p>
<?php endif; ?>
</article>
Use !empty() rather than isset() — it handles both missing keys and empty strings in one check.
Handling arrays — post loops
When an Astro component receives an array of items:
export interface Props {
posts: { title: string; url: string; date: string }[];
}
The PHP equivalent uses foreach:
<?php // partials/post-list.php ?>
<ul class="post-list">
<?php foreach ($fm['data']['posts'] as $post): ?>
<li>
<a href="<?= esc_url($post['url']) ?>">
<?= esc_html($post['title']) ?>
</a>
<time><?= esc_html($post['date']) ?></time>
</li>
<?php endforeach; ?>
</ul>
What the INTEGRATION.md tells the WordPress developer
When Frontmatter Solo generates the render pack, the INTEGRATION.md contains an entry for every partial:
## Partial: card
| Variable | Type | Required | Notes |
|----------------|---------|----------|--------------------------|
| fm.props.title | string | yes | |
| fm.props.excerpt | string | no | truncate to 160 chars |
| fm.props.url | string | yes | full permalink |
| fm.props.image | string | no | post thumbnail URL |
| fm.props.badge | string | no | e.g. "New", "Sale" |
The WordPress developer reads this once and knows exactly what to wire in each page template. No guessing. No reading the Astro source.
What Solo generates vs what you write
Solo generates:
- all partials with correct variable slots and escaping
- page templates with include calls
- base layout (header/footer structure)
manifest.jsonwith the full variable mapINTEGRATION.mdwith per-partial documentation
You write:
functions.php— enqueue scripts, register menus, define theme support- data fetching in page templates —
get_field(),get_post_meta(), WP_Query - any WordPress-specific hooks or filters
The split is clean. Solo owns the rendering layer. WordPress owns the data layer.
Frontmatter Solo focuses on generating templates and structure. It does not replace WordPress theme development or backend logic.
Scope
Frontmatter Solo:
- generates templates (Twig or PHP)
- defines a variable contract (manifest + INTEGRATION.md)
It does not:
- integrate with your backend
- fetch data
- configure a CMS
- provide runtime behavior
Integration is explicit and owned by the host application.