Astro for WordPress Developers

On this page

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.

Key Paradigm Shifts

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.

Project Structure

WordPress themes have a flat structure with magic filenames. Astro uses explicit directories:

WordPressAstroPurpose
index.php, single.phpsrc/pages/Routes (URLs)
template-parts/src/components/Reusable UI pieces
header.php + footer.phpsrc/layouts/Page wrappers
style.csssrc/styles/Global CSS
functions.phpastro.config.mjsSite configuration

The following tree shows a typical Astro project layout:

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 Components

.astro files are Astro’s equivalent of PHP templates. Each file has two parts:

  1. Frontmatter (between --- fences) — Server-side code, like PHP at the top of a template
  2. Template — HTML with expressions, like the rest of a PHP template

The following component declares typed props in the frontmatter and renders them in the template:

---
// Frontmatter: runs on server, never sent to browser
interface 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:

  • Frontmatter is isolated. Variables declared there are available in the template, but the code itself never reaches the browser.
  • Imports go in frontmatter. Components, data, utilities—all imported at the top.
  • TypeScript works. Define prop types with interface Props for editor autocomplete and validation.

Template Expressions

Astro templates use {curly braces} instead of <?php ?> tags. The syntax is JSX-like but outputs pure HTML.

Astro

---
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

<?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; ?>

Expression Patterns

PatternPurpose
{variable}Output a value
{condition && <Element />}Conditional rendering
{condition ? <A /> : <B />}If/else
{items.map(item => <Li>{item}</Li>)}Loops

Props and Slots

Components receive data through props (like function arguments) and slots (like do_action insertion points).

Astro

---
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>

The following markup uses that component, passing the default slot and a named footer slot:

<Card title="Hello" featured>
  <p>This goes in the default slot.</p>
  <footer slot="footer">Footer content</footer>
</Card>

PHP

<?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>

Props vs $args

In WordPress, get_template_part() passes data via the $args array. Astro props are typed and destructured:

---
// Type-safe with defaults
interface Props {
  title: string;
  count?: number;
}
const { title, count = 10 } = Astro.props;
---

Slots vs Hooks

WordPress uses do_action() to create insertion points. Astro uses slots:

WordPressAstro
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

Layouts wrap pages with common HTML structure—the <head>, header, footer, and anything shared across pages. This replaces header.php + footer.php. The following layout defines that shared shell and exposes a slot for page content:

---
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>&copy; {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>

Styling

Astro offers several styling approaches. The most distinctive is scoped styles.

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, so component styles stay contained without escalating selector specificity.

Global Styles

For site-wide styles, create a CSS file and import it in a layout:

---
import "../styles/global.css";
---

Conditional Classes

The class:list directive replaces manual class string building:

Astro

---
const { featured, size = "medium" } = Astro.props;
---
<article class:list={[
  "card",
  size,
  { featured, "has-border": true }
]}>

Output: <article class="card medium featured has-border">

PHP

<?php
$classes = ['card', $size];
if ($featured) $classes[] = 'featured';
if (true) $classes[] = 'has-border';
?>
<article class="<?php echo esc_attr(implode(' ', $classes)); ?>">

Client-Side JavaScript

Astro ships zero JavaScript by default. This is the biggest mental shift from WordPress.

Adding Interactivity

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.

Advanced interactive components

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. The following page loads a component only when it scrolls into view:

---
import SearchWidget from "../components/SearchWidget.jsx";
---
<!-- Only load JavaScript when the search box scrolls into view -->
<SearchWidget client:visible />
DirectiveWhen JavaScript loads
client:loadImmediately on page load
client:visibleWhen component enters viewport
client:idleWhen browser is idle

Routing

Astro uses file-based routing. Files in src/pages/ become URLs:

FileURL
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].astroAny path (catch-all)

Dynamic Routes

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 generate
export 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>

Compared to WordPress

WordPressAstro
Template hierarchy (single-post.php)Explicit file: posts/[slug].astro
Rewrite rules + query_varsFile structure
$wp_query determines templateURL maps directly to file
add_rewrite_rule()Create files or folders

Where WordPress Concepts Live

A reference for finding the Astro/EmDash equivalent of WordPress features:

Templating

WordPressAstro/EmDash
Template hierarchyFile-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 tagsTemplate expressions {value}
body_class()class:list directive

Data and Queries

WordPressAstro/EmDash
WP_QuerygetEmDashCollection(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)

Extensibility

WordPressAstro/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
PluginsAstro integrations + EmDash plugins

Content Types

WordPressAstro/EmDash
register_post_type()Create collection in admin UI
register_taxonomy()Create taxonomy in admin UI
register_meta()Add field to collection schema
Post statusEntry status (draft, published, etc.)
Featured imageMedia reference field
Gutenberg blocksPortable Text blocks

Concept Mapping

The main WordPress-to-Astro shifts covered in this guide:

  • PHP templates become Astro components: server code plus HTML, with explicit file organization.
  • Template tags become props and imports: data flows through arguments instead of globals.
  • Theme files become a pages directory: URLs match the file structure.
  • Hooks become slots and middleware: insertion points are defined where content is passed in.
  • jQuery loads by default in WordPress; Astro ships no JavaScript until you add it.

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.