Server-rendered by default
Like PHP, Astro code runs on the server. Unlike PHP, it outputs static HTML by default with zero JavaScript.
Astro is a web framework for building content-focused websites. When using EmDash, Astro replaces your WordPress theme—it handles templating, routing, and rendering.
This guide teaches Astro fundamentals by mapping them to WordPress concepts you already understand.
Server-rendered by default
Like PHP, Astro code runs on the server. Unlike PHP, it outputs static HTML by default with zero JavaScript.
Zero JS unless you add it
WordPress loads jQuery and theme scripts automatically. Astro ships nothing to the browser unless you explicitly add it.
Component-based architecture
Instead of scattered template tags and includes, build with composable, self-contained components.
File-based routing
No rewrite rules or query_vars. The file structure in src/pages/ defines your URLs directly.
WordPress themes have a flat structure with magic filenames. Astro uses explicit directories:
| WordPress | Astro | Purpose |
|---|---|---|
index.php, single.php | src/pages/ | Routes (URLs) |
template-parts/ | src/components/ | Reusable UI pieces |
header.php + footer.php | src/layouts/ | Page wrappers |
style.css | src/styles/ | Global CSS |
functions.php | astro.config.mjs | Site configuration |
A typical Astro project:
src/├── components/ # Reusable UI (Header, PostCard, etc.)├── layouts/ # Page shells (Base.astro)├── pages/ # Routes - files become URLs│ ├── index.astro # → /│ ├── posts/│ │ ├── index.astro # → /posts│ │ └── [slug].astro # → /posts/hello-world│ └── [slug].astro # → /about, /contact, etc.└── styles/ └── global.css.astro files are Astro’s equivalent of PHP templates. Each file has two parts:
--- fences) — Server-side code, like PHP at the top of a template---// Frontmatter: runs on server, never sent to browserinterface Props { title: string; excerpt: string; url: string;}
const { title, excerpt, url } = Astro.props;---<!-- Template: outputs HTML --><article class="post-card"> <h2><a href={url}>{title}</a></h2> <p>{excerpt}</p></article>Key differences from PHP:
interface Props for editor autocomplete and validation.Astro templates use {curly braces} instead of <?php ?> tags. The syntax is JSX-like but outputs pure HTML.
---import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts");const showTitle = true;---{showTitle && <h1>Latest Posts</h1>}
{posts.length > 0 ? ( <ul> {posts.map(post => ( <li> <a href={`/posts/${post.id}`}>{post.data.title}</a> </li> ))} </ul>) : ( <p>No posts found.</p>)}<?php$posts = new WP_Query(['post_type' => 'post']);$show_title = true;?>
<?php if ($show_title): ?> <h1>Latest Posts</h1><?php endif; ?>
<?php if ($posts->have_posts()): ?> <ul> <?php while ($posts->have_posts()): $posts->the_post(); ?> <li> <a href="<?php the_permalink(); ?>"><?php the_title(); ?></a> </li> <?php endwhile; wp_reset_postdata(); ?> </ul><?php else: ?> <p>No posts found.</p><?php endif; ?>| Pattern | Purpose |
|---|---|
{variable} | Output a value |
{condition && <Element />} | Conditional rendering |
{condition ? <A /> : <B />} | If/else |
{items.map(item => <Li>{item}</Li>)} | Loops |
Components receive data through props (like function arguments) and slots (like do_action insertion points).
---interface Props { title: string; featured?: boolean;}
const { title, featured = false } = Astro.props;---<article class:list={["card", { featured }]}> <h2>{title}</h2> <slot /> <slot name="footer" /></article>Usage:
<Card title="Hello" featured> <p>This goes in the default slot.</p> <footer slot="footer">Footer content</footer></Card><?php// Usage: get_template_part('template-parts/card', null, [// 'title' => 'Hello',// 'featured' => true// ]);
$title = $args['title'] ?? '';$featured = $args['featured'] ?? false;$class = $featured ? 'card featured' : 'card';?><article class="<?php echo esc_attr($class); ?>"> <h2><?php echo esc_html($title); ?></h2> <?php // No direct equivalent to slots. // WordPress uses do_action() for similar patterns: do_action('card_content'); do_action('card_footer'); ?></article>$argsIn WordPress, get_template_part() passes data via the $args array. Astro props are typed and destructured:
---// Type-safe with defaultsinterface Props { title: string; count?: number;}const { title, count = 10 } = Astro.props;---WordPress uses do_action() to create insertion points. Astro uses slots:
| WordPress | Astro |
|---|---|
do_action('before_content') | <slot name="before" /> |
| Default content area | <slot /> |
do_action('after_content') | <slot name="after" /> |
The difference: slots receive child elements at the call site, while WordPress hooks require separate add_action() calls elsewhere.
Layouts wrap pages with common HTML structure—the <head>, header, footer, and anything shared across pages. This replaces header.php + footer.php.
---import "../styles/global.css";
interface Props { title: string; description?: string;}
const { title, description = "My EmDash Site" } = Astro.props;---<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="description" content={description} /> <title>{title}</title> </head> <body> <header> <nav><!-- Navigation --></nav> </header>
<main> <slot /> </main>
<footer> <p>© {new Date().getFullYear()}</p> </footer> </body></html>Use the layout in a page:
---import Base from "../layouts/Base.astro";---<Base title="Home"> <h1>Welcome</h1> <p>Page content goes in the slot.</p></Base>Astro offers several styling approaches. The most distinctive is scoped styles.
Styles in a <style> tag are automatically scoped to that component:
<article class="card"> <h2>Title</h2></article>
<style> /* Only affects .card in THIS component */ .card { padding: 1rem; border: 1px solid #ddd; }
h2 { color: navy; }</style>The generated HTML includes unique class names to prevent style leakage. No more specificity wars.
For site-wide styles, create a CSS file and import it in a layout:
---import "../styles/global.css";---The class:list directive replaces manual class string building:
---const { featured, size = "medium" } = Astro.props;---<article class:list={[ "card", size, { featured, "has-border": true }]}>Output: <article class="card medium featured has-border">
<?php$classes = ['card', $size];if ($featured) $classes[] = 'featured';if (true) $classes[] = 'has-border';?><article class="<?php echo esc_attr(implode(' ', $classes)); ?>">Astro ships zero JavaScript by default. This is the biggest mental shift from WordPress.
For simple interactions, add a <script> tag:
<button id="menu-toggle">Menu</button><nav id="mobile-menu" hidden> <slot /></nav>
<script> const toggle = document.getElementById("menu-toggle"); const menu = document.getElementById("mobile-menu");
toggle?.addEventListener("click", () => { menu?.toggleAttribute("hidden"); });</script>Scripts are bundled and deduplicated automatically. If this component appears twice on a page, the script runs once.
For more complex interactivity, Astro can load JavaScript components (React, Vue, Svelte) on demand. This is optional—most sites work fine with just <script> tags.
---import SearchWidget from "../components/SearchWidget.jsx";---<!-- Only load JavaScript when the search box scrolls into view --><SearchWidget client:visible />| Directive | When JavaScript loads |
|---|---|
client:load | Immediately on page load |
client:visible | When component enters viewport |
client:idle | When browser is idle |
Astro uses file-based routing. Files in src/pages/ become URLs:
| File | URL |
|---|---|
src/pages/index.astro | / |
src/pages/about.astro | /about |
src/pages/posts/index.astro | /posts |
src/pages/posts/[slug].astro | /posts/hello-world |
src/pages/[...slug].astro | Any path (catch-all) |
For CMS content, use bracket syntax for dynamic segments:
---import { getEmDashCollection, getEmDashEntry } from "emdash";import Base from "../../layouts/Base.astro";import { PortableText } from "emdash/ui";
// For static builds, define which pages to generateexport async function getStaticPaths() { const { entries: posts } = await getEmDashCollection("posts"); return posts.map(post => ({ params: { slug: post.id }, props: { post }, }));}
const { post } = Astro.props;---<Base title={post.data.title}> <article> <h1>{post.data.title}</h1> <PortableText value={post.data.content} /> </article></Base>| WordPress | Astro |
|---|---|
Template hierarchy (single-post.php) | Explicit file: posts/[slug].astro |
Rewrite rules + query_vars | File structure |
$wp_query determines template | URL maps directly to file |
add_rewrite_rule() | Create files or folders |
A reference for finding the Astro/EmDash equivalent of WordPress features:
| WordPress | Astro/EmDash |
|---|---|
| Template hierarchy | File-based routing in src/pages/ |
get_template_part() | Import and use components |
the_content() | <PortableText value={content} /> |
the_title(), the_*() | Access via post.data.title |
| Template tags | Template expressions {value} |
body_class() | class:list directive |
| WordPress | Astro/EmDash |
|---|---|
WP_Query | getEmDashCollection(type, filters) |
get_post() | getEmDashEntry(type, id) |
get_posts() | getEmDashCollection(type) |
get_the_terms() | Access via entry.data.categories |
get_post_meta() | Access via entry.data.fieldName |
get_option() | getSiteSettings() |
wp_nav_menu() | getMenu(location) |
| WordPress | Astro/EmDash |
|---|---|
add_action() | EmDash hooks, Astro middleware |
add_filter() | EmDash hooks |
add_shortcode() | Portable Text custom blocks |
register_block_type() | Portable Text custom blocks |
register_sidebar() | EmDash widget areas |
| Plugins | Astro integrations + EmDash plugins |
| WordPress | Astro/EmDash |
|---|---|
register_post_type() | Create collection in admin UI |
register_taxonomy() | Create taxonomy in admin UI |
register_meta() | Add field to collection schema |
| Post status | Entry status (draft, published, etc.) |
| Featured image | Media reference field |
| Gutenberg blocks | Portable Text blocks |
The jump from WordPress to Astro is significant but logical:
Start with the Getting Started guide to build your first EmDash site, or explore Working with Content to learn how to query and render CMS data.