---
title: Astro for WordPress Developers
description: Learn Astro fundamentals through the lens of WordPress concepts you already know
---

import { Aside, Card, CardGrid, Tabs, TabItem } from "@astrojs/starlight/components";

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

<CardGrid>
	<Card title="Server-rendered by default" icon="server">
		Like PHP, Astro code runs on the server. Unlike PHP, it outputs static HTML by default with zero
		JavaScript.
	</Card>
	<Card title="Zero JS unless you add it" icon="rocket">
		WordPress loads jQuery and theme scripts automatically. Astro ships nothing to the browser
		unless you explicitly add it.
	</Card>
	<Card title="Component-based architecture" icon="puzzle">
		Instead of scattered template tags and includes, build with composable, self-contained
		components.
	</Card>
	<Card title="File-based routing" icon="document">
		No rewrite rules or `query_vars`. The file structure in `src/pages/` defines your URLs directly.
	</Card>
</CardGrid>

## Project Structure

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

```astro title="src/components/PostCard.astro"
---
// 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.

<Tabs>
<TabItem label="Astro">

```astro title="src/components/PostList.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>
)}
```

</TabItem>
<TabItem label="PHP">

```php title="template-parts/post-list.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; ?>
```

</TabItem>
</Tabs>

### Expression Patterns

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

<Aside>
	Unlike PHP, you don't need to escape output. Astro escapes expressions by default, preventing XSS
	vulnerabilities.
</Aside>

## Props and Slots

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

<Tabs>
<TabItem label="Astro">

```astro title="src/components/Card.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>
```

Usage:

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

</TabItem>
<TabItem label="PHP">

```php title="template-parts/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>
```

</TabItem>
</Tabs>

### Props vs `$args`

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

```astro
---
// 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:

| 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

Layouts wrap pages with common HTML structure—the `<head>`, header, footer, and anything shared across pages. This replaces `header.php` + `footer.php`.

```astro title="src/layouts/Base.astro"
---
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:

```astro title="src/pages/index.astro"
---
import Base from "../layouts/Base.astro";
---
<Base title="Home">
  <h1>Welcome</h1>
  <p>Page content goes in the slot.</p>
</Base>
```

<Aside type="tip">
	Unlike `get_header()` and `get_footer()`, layouts keep the entire HTML structure in one file. This
	makes it easier to see the full page structure and pass data between sections.
</Aside>

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

```astro title="src/components/Card.astro"
<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.

### Global Styles

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

```astro title="src/layouts/Base.astro"
---
import "../styles/global.css";
---
```

### Conditional Classes

The `class:list` directive replaces manual class string building:

<Tabs>
<TabItem label="Astro">

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

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

</TabItem>
<TabItem label="PHP">

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

</TabItem>
</Tabs>

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

```astro title="src/components/MobileMenu.astro"
<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.

```astro title="src/pages/index.astro"
---
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           |

<Aside type="tip">
This is entirely optional. You can build full EmDash sites without touching React, Vue, or any JavaScript framework. The `<script>` tag approach handles most interactive needs.
</Aside>

## Routing

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

### Dynamic Routes

For CMS content, use bracket syntax for dynamic segments:

```astro title="src/pages/posts/[slug].astro"
---
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

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

<Aside type="tip">
	No more guessing which template WordPress will use. The URL `/posts/hello` always loads
	`src/pages/posts/[slug].astro`.
</Aside>

## Where WordPress Concepts Live

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

### Templating

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

### Data and Queries

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

### Extensibility

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

### Content Types

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

## Summary

The jump from WordPress to Astro is significant but logical:

1. **PHP templates → Astro components** — Same idea (server code + HTML), better organization
2. **Template tags → Props and imports** — Explicit data flow instead of globals
3. **Theme files → Pages directory** — URLs match file structure
4. **Hooks → Slots and middleware** — More predictable insertion points
5. **jQuery by default → Zero JS by default** — Add interactivity intentionally

Start with the [Getting Started](/getting-started/) guide to build your first EmDash site, or explore [Working with Content](/guides/working-with-content/) to learn how to query and render CMS data.
