Skip to content

Port a WordPress Theme

Port a WordPress theme by separating its design, route templates, and database-managed features. The result is a complete Astro project with EmDash queries and a seed file, not a PHP theme loaded by a theme runtime.

Import representative content before converting templates. Record the WordPress post types, taxonomies, permalink rules, menus, widget areas, Customizer values, shortcodes, and plugin-generated markup used by the site.

Choose the new URL for each content type. Astro routes are explicit files, so there is no automatic equivalent of the WordPress template hierarchy.

The current blog template uses this mapping:

WordPress EmDash template path
front-page.php or home.php src/pages/index.astro
single.php src/pages/posts/[slug].astro
archive.php src/pages/posts/index.astro
page.php src/pages/pages/[slug].astro
category.php src/pages/category/[slug].astro
tag.php src/pages/tag/[slug].astro
search.php src/pages/search.astro
404.php src/pages/404.astro
header.php and footer.php src/layouts/Base.astro
template-parts/content.php src/components/PostCard.astro or another component

The starter template instead uses src/pages/[slug].astro for pages. Pick one route model and make the seed’s urlPattern and redirects agree with it.

Collect the source files that determine the rendered design:

  • style.css and enqueued stylesheets.
  • theme.json for a block theme.
  • Template markup and template parts.
  • Fonts, icons, and images with their licenses.
  • Responsive breakpoints and interactive behavior.

Copy design tokens into the Astro project’s CSS and build the shared document shell in src/layouts/Base.astro. Convert repeated template parts to components before expanding individual routes.

Do not copy WordPress-generated class names or JavaScript when the new component does not need their behavior. Preserve observable layout and interaction, not implementation residue.

WP_Query becomes getEmDashCollection(). Filter and order in the query so the database does the work.

The following examples render a latest-posts archive:

archive.php
<?php
$posts = new WP_Query([
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 12,
'orderby' => 'date',
'order' => 'DESC',
]);
while ($posts->have_posts()) :
$posts->the_post();
?>
<article>
<h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
</article>
<?php endwhile; wp_reset_postdata(); ?>

Use orderBy, not sort or sortBy. The keys are stored field names such as published_at, created_at, or an indexed collection field.

Use the slug from Astro.params to call getEmDashEntry(). Render rich text with PortableText.

src/pages/posts/[slug].astro
---
import { decodeSlug, getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.rewrite("/404");
const { entry: post, error, cacheHint } = await getEmDashEntry("posts", slug);
if (error) return new Response("Could not load the post", { status: 500 });
if (!post) return Astro.rewrite("/404");
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---
<Base title={post.data.title} content={{ collection: "posts", id: post.data.id, slug }}>
<article>
<h1 {...post.edit.title}>{post.data.title}</h1>
<PortableText value={post.data.content} />
</article>
</Base>

post.id is Astro’s route identifier and is normally the slug. post.data.id is the stored EmDash content ID. Use data.id for comments, taxonomy helpers, and visual-editing context.

Resolve a term with its taxonomy name, then use that name in the collection where filter. The current blog template uses singular taxonomy names and route directories:

src/pages/category/[slug].astro
---
import { decodeSlug, getEmDashCollection, getTerm } from "emdash";
const slug = decodeSlug(Astro.params.slug);
const term = slug ? await getTerm("category", slug, { includeCounts: false }) : null;
if (!term) return Astro.rewrite("/404");
const { entries: posts } = await getEmDashCollection("posts", {
where: { category: term.slug },
orderBy: { published_at: "desc" },
});
---
<h1>{term.label}</h1>
{posts.map((post) => <a href={`/posts/${post.id}`}>{post.data.title}</a>)}

Use getTermsForEntries() with entry.data.id when an archive needs terms for many entries. That batches the relationship query instead of calling a per-entry helper in a loop.

Create menu definitions in seed/seed.json and query them with getMenu("primary"). Menu items can be reordered and edited after setup, so do not duplicate the same navigation as hard-coded links.

The following component preserves nested menu items and marks the current page:

src/components/PrimaryNav.astro
---
import { getMenu } from "emdash";
const menu = await getMenu("primary");
---
{menu && (
<nav aria-label="Primary navigation">
<ul>
{menu.items.map((item) => (
<li>
<a href={item.url} aria-current={Astro.url.pathname === item.url ? "page" : undefined}>
{item.label}
</a>
{item.children.length > 0 && (
<ul>
{item.children.map((child) => (
<li><a href={child.url}>{child.label}</a></li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
)}

Define widget areas in the seed and render them with <WidgetArea name="sidebar" /> from emdash/ui. Use component widgets only when the template registers the matching component ID.

src/layouts/Post.astro
---
import { WidgetArea } from "emdash/ui";
---
<main><slot /></main>
<aside aria-label="Related content">
<WidgetArea name="sidebar" />
</aside>

Read site title, tagline, logo, and favicon with getSiteSettings(). Static legal or design copy can remain in the template, but administrator-managed identity must come from settings.

WordPress value EmDash setting
Site title title
Tagline tagline
Custom logo logo
Site icon favicon
Posts per page postsPerPage

If editors must choose among multiple page layouts, add a select field to the pages collection and map each stored value to a known component. Do not turn a stored string into an arbitrary import path.

Map content-bearing shortcodes and WordPress blocks to Portable Text shapes. Use the ordinary PortableText renderer for built-in blocks and a component map for custom _type values.

A seed can provide custom block data, but it does not register a custom editor. Use a native plugin when the port requires packaged Astro renderers or custom React editing UI.

For example, map a WordPress gallery shortcode to a namespaced _type such as publication.gallery, create a Gallery.astro renderer, and pass it through the PortableText component map:

src/components/ArticleBody.astro
---
import type { PortableTextBlock } from "emdash";
import { PortableText } from "emdash/ui";
import Gallery from "./blocks/Gallery.astro";
interface Props {
value: PortableTextBlock[];
}
const { value } = Astro.props;
const customTypes = { "publication.gallery": Gallery };
---
<PortableText value={value} components={{ type: customTypes }} />

The seed belongs at seed/seed.json in current templates, with package.json#emdash.seed pointing to it. Declare every collection field, taxonomy, menu, widget area, section, and sample entry that the template assumes.

The following fragment defines the route-facing fields for posts:

seed/seed.json
{
"$schema": "https://emdashcms.com/seed.schema.json",
"version": "1",
"collections": [
{
"slug": "posts",
"label": "Posts",
"labelSingular": "Post",
"supports": ["drafts", "revisions", "search", "seo"],
"urlPattern": "/posts/{slug}",
"fields": [
{ "slug": "title", "label": "Title", "type": "string", "required": true },
{ "slug": "content", "label": "Content", "type": "portableText" },
{ "slug": "excerpt", "label": "Excerpt", "type": "text" },
{ "slug": "featured_image", "label": "Featured image", "type": "image" }
]
}
]
}

Read Seed files for content IDs, references, localization, media, validation, and apply options.

  1. Import a representative WordPress export and record any unsupported blocks or fields.

  2. Start from the current EmDash template for the deployment target.

  3. Implement the shared layout, tokens, typography, and responsive shell.

  4. Implement archive and single-entry routes using the imported collection slugs and fields.

  5. Add taxonomy routes, search, menus, widget areas, and site settings that the source theme actually uses.

  6. Add seed definitions for every route dependency and test setup against an empty database.

  7. Compare representative public URLs at mobile and desktop widths. Test empty fields, long titles, missing images, drafts, and 404 routes.

  8. Prepare redirects for every changed permalink before cutover.

  • Child themes: combine the resolved parent and child output. Port the effective templates and styles, not only the child directory.
  • Block themes: use theme.json as a source for design tokens and templates/*.html as content structure, then express the result as Astro components.
  • Page builders: import representative pages first. Builder shortcodes and proprietary JSON usually require a deliberate Portable Text conversion or a redesigned page template.
  • WooCommerce themes: treat commerce behavior as a separate application integration. Porting presentation files does not replace product, cart, checkout, payment, or order semantics.