TypeScript instead of PHP
Templates are Astro components. The syntax is cleaner, but the concept is the same: server code that outputs HTML.
EmDash brings familiar WordPress concepts—posts, pages, taxonomies, menus, widgets, and a media library—into a modern Astro stack. Your content management knowledge transfers directly.
The concepts you know from WordPress are first-class features in EmDash:
The implementation changes, but the mental model stays the same:
TypeScript instead of PHP
Templates are Astro components. The syntax is cleaner, but the concept is the same: server code that outputs HTML.
Content APIs instead of WP_Query
Query functions like getEmDashCollection() replace WP_Query. No SQL, just function calls.
File-based routing
Files in src/pages/ become URLs. No rewrite rules or template hierarchy to memorize.
Components instead of template parts
Import and use components. Same idea as get_template_part(), better organization.
| WordPress | EmDash | Notes |
|---|---|---|
| Custom Post Types | Collections | Define via admin UI or API |
WP_Query | getEmDashCollection() | Filters, limits, taxonomy queries |
get_post() | getEmDashEntry() | Returns entry or null |
| Categories/Tags | Taxonomies | Hierarchical support preserved |
register_nav_menus() | getMenu() | First-class menu support |
register_sidebar() | getWidgetArea() | First-class widget areas |
bloginfo('name') | getSiteSetting("title") | Site settings API |
the_content() | <PortableText /> | Structured content rendering |
| Shortcodes | Portable Text blocks | Custom components |
add_action/filter() | Plugin hooks | content:beforeSave, etc. |
wp_options | ctx.kv | Key-value storage |
| Theme directory | src/ directory | Components, layouts, pages |
functions.php | astro.config.mjs + EmDash config | Build and runtime config |
WordPress queries use WP_Query or helper functions. EmDash uses typed query functions.
<?php$posts = new WP_Query([ 'post_type' => 'post', 'posts_per_page' => 10, 'post_status' => 'publish', 'category_name' => 'news',]);
while ($posts->have_posts()) :$posts->the_post();?>
<h2><?php the_title(); ?></h2> <?php the_excerpt(); ?><?php endwhile; ?>---import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts", {status: "published",limit: 10,where: { category: "news" },});
---
{posts.map((post) => (
<article> <h2>{post.data.title}</h2> <p>{post.data.excerpt}</p> </article>))}The following examples fetch one entry by identifier and render it, in WordPress and in EmDash:
<?php$post = get_post($id);?><article> <h1><?php echo $post->post_title; ?></h1> <?php echo apply_filters('the_content', $post->post_content); ?></article>---import { getEmDashEntry } from "emdash";import { PortableText } from "emdash/ui";
const { slug } = Astro.params;const { entry: post } = await getEmDashEntry("posts", slug);
if (!post) return Astro.redirect("/404");---
<article> <h1>{post.data.title}</h1> <PortableText value={post.data.content} /></article>WordPress uses a template hierarchy to select which file renders a page. Astro uses explicit file-based routing.
| WordPress Template | EmDash Equivalent |
|---|---|
index.php | src/pages/index.astro |
single.php | src/pages/posts/[slug].astro |
single-{type}.php | src/pages/{type}/[slug].astro |
page.php | src/pages/pages/[slug].astro |
archive.php | src/pages/posts/index.astro |
archive-{type}.php | src/pages/{type}/index.astro |
category.php | src/pages/categories/[slug].astro |
tag.php | src/pages/tags/[slug].astro |
search.php | src/pages/search.astro |
404.php | src/pages/404.astro |
header.php / footer.php | src/layouts/Base.astro |
sidebar.php | src/components/Sidebar.astro |
WordPress template parts become Astro components:
// In template:get_template_part('template-parts/content', 'post');
// template-parts/content-post.php:
<article class="post"> <h2><?php the_title(); ?></h2> <?php the_excerpt(); ?></article>---const { post } = Astro.props;---
<article class="post"> <h2>{post.data.title}</h2> <p>{post.data.excerpt}</p></article>The following page imports that component and renders it for each post:
---import PostCard from "../components/PostCard.astro";import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts");---
{posts.map((post) => <PostCard {post} />)}EmDash has first-class menu support with automatic URL resolution:
<?phpwp_nav_menu([ 'theme_location' => 'primary', 'container' => 'nav',]);?>---import { getMenu } from "emdash";
const menu = await getMenu("primary");---
<nav> <ul> {menu?.items.map((item) => ( <li> <a href={item.url}>{item.label}</a> </li> ))} </ul></nav>Menus are created via the admin UI, seed files, or WordPress import.
Widget areas work like sidebars in WordPress:
<?php if (is_active_sidebar('sidebar-1')) : ?> <aside> <?php dynamic_sidebar('sidebar-1'); ?> </aside><?php endif; ?>---import { getWidgetArea } from "emdash";import { PortableText } from "emdash/ui";
const sidebar = await getWidgetArea("sidebar");---
{sidebar && (
<aside> {sidebar.widgets.map((widget) => { if (widget.type === "content") { return <PortableText value={widget.content} />; } // Handle other widget types })} </aside>)}Site options and customizer settings map to getSiteSetting():
| WordPress | EmDash |
|---|---|
bloginfo('name') | getSiteSetting("title") |
bloginfo('description') | getSiteSetting("tagline") |
get_custom_logo() | getSiteSetting("logo") |
get_option('date_format') | getSiteSetting("dateFormat") |
home_url() | Astro.site |
The following example reads individual settings with getSiteSetting():
import { getSiteSetting } from "emdash";
const title = await getSiteSetting("title");const logo = await getSiteSetting("logo"); // Returns { mediaId, alt, url }Taxonomies work the same conceptually—hierarchical (like categories) or flat (like tags):
import { getTaxonomyTerms, getEntryTerms, getTerm } from "emdash";
// Get all categoriesconst categories = await getTaxonomyTerms("categories");
// Get a specific termconst news = await getTerm("categories", "news");
// Get terms for a postconst postCategories = await getEntryTerms("posts", postId, "categories");WordPress hooks (add_action, add_filter) become EmDash plugin hooks:
| WordPress Hook | EmDash Hook | Purpose |
|---|---|---|
save_post | content:beforeSave | Modify content before saving |
the_content | PortableText components | Transform rendered content |
pre_get_posts | Query options | Filter queries |
wp_head | Layout <head> | Add head content |
wp_footer | Layout before </body> | Add footer content |
Type Safety
TypeScript throughout. Collections, queries, and components are fully typed, so field names and return types autocomplete and are checked at build time.
Performance
Static generation by default, with server rendering when needed. Edge deployment ready.
Modern DX
Hot module replacement. Component-based architecture. Modern tooling (Vite, TypeScript, ESLint).
Git-based Deployments
Code and templates live in git; content lives in the database. Deploy by pushing code.
EmDash generates secure preview URLs with HMAC-signed tokens. Content editors share a preview link to show a draft, so reviewers can see it without a production login.
EmDash plugins run in isolated contexts with explicit APIs. Each plugin reaches only the APIs it declares, so plugins do not share or overwrite each other’s global state.
Content editors use the EmDash admin panel, similar to wp-admin:
The editing experience is familiar. The technology underneath is modern.
EmDash imports WordPress content directly:
.xml file in EmDash’s adminPosts, pages, taxonomies, menus, and media transfer. Gutenberg blocks convert to Portable Text. Custom fields are analyzed and mapped.
See the WordPress Migration Guide for complete instructions.