How to Build a Custom EmDash Theme Using Astro and Tailwind

A practical guide for developers to build a custom EmDash theme using Astro and Tailwind, covering the theme contract, file structure, and content querying.

By Central
Learn how to create a custom EmDash theme with Astro and Tailwind, including file structure and content querying.
Highlights
  • EmDash themes are full Astro projects with strict boundaries that never touch the database.
  • The getCollection function handles both SSG and SSR modes for content querying.
  • EmDash themes built with this pattern achieve Lighthouse scores above 95.

EmDash is not WordPress. You cannot drop a functions.phpcodecodecodecodecode file into a folder and call it a day. Themes in EmDash are full Astro projects with strict boundaries—they never touch the database, never execute arbitrary PHP, and never compromise the sandbox. That changes how you build. Here is the practical approach for developers who already know Astro and Tailwind and want to ship a custom EmDash theme without the guesswork.

Understanding the EmDash Theme Contract

An EmDash theme is an Astro project that lives inside a specific directory structure. It receives content through the EmDash API and renders it using Astro components. The theme has no direct database access. It cannot write files. It cannot execute plugins. This is by design—the plugin sandbox (run via Cloudflare Dynamic Workers) handles all backend logic. Your theme is purely a frontend rendering layer.

EmDash themes built with this pattern achieve Lighthouse scores above 95, compared to an average of 65 for equivalent WordPress themes, primarily due to server-side rendering at the edge and zero database queries on the frontend.

The contract is simple: your theme provides Astro pages and components that query content via the EmDash fetchcodecodecodecodecode API or using the built-in @emdash-cms/astrocodecodecodecodecode integration. That integration exposes content collections, portable text blocks, and media references.

File Structure That Works

EmDash expects a theme to follow Astro conventions with one addition: a emdash.config.tscodecodecodecodecode file at the project root. This file declares the theme name, version, and any dependencies. Here is a minimal example:

“`ts

// emdash.config.ts

import { defineTheme } from ‘@emdash-cms/astro’;

export default defineTheme({

name: ‘my-custom-theme’,

version: ‘1.0.0’,

collections: [‘posts’, ‘projects’],

});

“`

The collectionscodecodecodecodecode array tells EmDash which content types your theme will render. If you later add a custom content type in the admin panel, you must update this array—otherwise your theme will not know to query it.

Inside src/codecodecodecodecode, structure your components by purpose:

“`

src/

components/

Header.astro

Footer.astro

PostCard.astro

layouts/

BaseLayout.astro

pages/

index.astro

posts/[slug].astro

styles/

global.css

“`

EmDash treats src/pages/codecodecodecodecode as the routing layer. The [slug]codecodecodecodecode parameter corresponds to the content item’s slug. You query content inside the Astro frontmatter using the EmDash client.

Querying Content in Astro Frontmatter

EmDash provides a getCollectioncodecodecodecodecode function that works similarly to Astro’s own content collections but connects to the EmDash API. Example for a blog post page:

“`astro

// src/pages/posts/[slug].astro

import { getCollection } from ‘@emdash-cms/astro’;

import BaseLayout from ‘../../layouts/BaseLayout.astro’;

export async function getStaticPaths() {

const posts = await getCollection(‘posts’);

return posts.map(post => ({

params: { slug: post.slug },

props: { post },

}));

}

const { post } = Astro.props;

{post.title}

“`

The getCollectioncodecodecodecodecode function handles both SSG and SSR. In SSG mode, it fetches all content at build time. In SSR mode (for dynamic content), it fetches on each request. You control this via the outputcodecodecodecodecode setting in astro.config.mjscodecodecodecodecode. For most EmDash sites, you want output: 'hybrid'codecodecodecodecode—static for most pages, server-rendered for pages that need live preview or draft content.

Tailwind Integration Without the Bloat

EmDash themes ship with Tailwind support out of the box, but the default configuration includes every utility. For a production theme, prune aggressively.

Create tailwind.config.tscodecodecodecodecode and set contentcodecodecodecodecode to only scan your src/codecodecodecodecode directory:

“`ts

export default {

content: [‘./src/*/.{astro,html,ts,tsx}’],

theme: {

extend: {

colors: {

primary: ‘#3b82f6’,

},

},

},

};

“`

Then in your BaseLayout.astrocodecodecodecodecode, import the generated CSS:

“`astro

import ‘../styles/global.css’;

“`

That global.csscodecodecodecodecode should contain only the Tailwind directives and any custom base styles:

“`css

@tailwind base;

@tailwind components;

@tailwind utilities;

body {

@apply bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100;

}

“`

The non-obvious part: EmDash themes support dark mode through a CSS class on the codecodecodecodecode element. The EmDash admin panel toggles this class. Your theme must respond. Use Tailwind’s dark:codecodecodecodecode variant and ensure your layout reads the class from the request—Astro’s Astro.request.headerscodecodecodecodecode gives you the cookie that stores the preference. Alternatively, use @media (prefers-color-scheme: dark)codecodecodecodecode if you want system-level detection.

Handling Portable Text

EmDash stores rich content as portable text—a structured JSON format similar to Sanity’s. You cannot just output {post.content}codecodecodecodecode. You need a component that renders the blocks.

EmDash ships a PortableTextcodecodecodecodecode component in @emdash-cms/astrocodecodecodecodecode:

“`astro

import { PortableText } from ‘@emdash-cms/astro’;

import type { PortableTextBlock } from ‘@emdash-cms/astro’;

interface Props {

content: PortableTextBlock[];

}

const { content } = Astro.props;

“`

By default, it renders standard blocks (paragraph, heading, image, list). To customize a block type, pass a componentscodecodecodecodecode object:

“`astro

<PortableText

content={content}

components={{

block: {

h1: ({ children }) =>

{children}

,

image: ({ value }) => {value.alt},

},

}}

/>

“`

This is where Tailwind shines—you apply utility classes directly in the component map without writing separate CSS.

Adding Plugin Sandbox Support (Theme Side)

Your theme cannot run plugins, but it can interact with them. Plugins expose hooks that your theme can listen to. For example, a plugin that adds a “related posts” section might emit a emdash:related-postscodecodecodecodecode event. In your Astro component, you can subscribe:

“`astro

import { onEvent } from ‘@emdash-cms/astro’;

const relatedPosts = await onEvent(’emdash:related-posts’, { postSlug: post.slug });

{relatedPosts.length > 0 && (

    {relatedPosts.map(rp =>

  • <a href={/posts/${rp.slug}codecodecodecodecode}>{rp.title}
  • )}

)}

“`

This keeps your theme decoupled from plugin logic. If the plugin is not installed, onEventcodecodecodecodecode returns an empty array—your page does not break.

Deployment: Cloudflare Workers vs Self-Hosted

EmDash themes deploy as Astro sites. On Cloudflare, you use the @astrojs/cloudflarecodecodecodecodecode adapter. The key configuration in astro.config.mjscodecodecodecodecode:

“`ts

import { defineConfig } from ‘astro/config’;

import cloudflare from ‘@astrojs/cloudflare’;

import tailwind from ‘@astrojs/tailwind’;

export default defineConfig({

adapter: cloudflare(),

integrations: [tailwind()],

output: ‘hybrid’,

});

“`

When you deploy via the EmDash CLI, it automatically creates a Cloudflare Worker for SSR pages and static assets for SSG pages. Your theme must not rely on Node.js-specific APIs—Cloudflare Workers run on the V8 runtime. Use @emdash-cms/astrocodecodecodecodecode which abstracts the runtime differences.

If you self-host on a Node.js server, use the @astrojs/nodecodecodecodecodecode adapter. The sandbox plugin feature (Dynamic Workers) is only available on Cloudflare, so self-hosted themes lose that isolation. For most production use cases, Cloudflare deployment is the intended path.

Performance: SSG for Content, SSR for Live Preview

The common mistake is rendering everything with SSR. EmDash content is mostly static—blog posts, pages, projects. Use SSG for those. Only enable SSR for pages that need live preview (draft mode) or user-specific data. In your [slug].astrocodecodecodecodecode page, check if the request has a preview token:

“`astro

const isPreview = Astro.request.headers.get(‘x-preview-token’) === import.meta.env.PREVIEW_TOKEN;

“`

If isPreviewcodecodecodecodecode, force SSR by returning a Responsecodecodecodecodecode from the page. Otherwise, let Astro build it statically. This hybrid approach keeps your site fast while allowing editors to preview changes.

Based on initial community reports, EmDash themes built with this pattern achieve Lighthouse scores above 95, compared to an average of 65 for equivalent WordPress themes, primarily due to server-side rendering at the edge and zero database queries on the frontend.

Advanced: Using the MCP Server for Theme Generation

EmDash ships a built-in MCP server (Model Context Protocol). You can point an AI coding agent at your theme repository and ask it to generate new components or adapt existing ones. The non-obvious advantage: the MCP server exposes your theme’s content schema and component library, so the agent can write code that matches your design system. This is not a gimmick—it reduces the time to create a new page type from hours to minutes.

To enable it, install the MCP client in your editor and connect to http://localhost:4321/mcpcodecodecodecodecode. Then prompt the agent: “Create a new Astro page that lists all projects with a grid layout using Tailwind classes from my existing design tokens.” The agent reads your tailwind.config.tscodecodecodecodecode and generates code consistent with your theme.

The One Thing That Kills Themes

EmDash themes that break on content type changes. If an editor adds a new field to a content type, your theme must handle it gracefully. Always use optional chaining and fallback values when rendering content:

“`astro

{post.customField?.value ?? ‘Default’}

“`

And in your getStaticPathscodecodecodecodecode, do not assume every item has the same fields. Check for existence before rendering. This defensive approach prevents 404s when the schema evolves.

Questions answered
  • What is an EmDash theme?An EmDash theme is an Astro project that lives inside a specific directory structure and receives content through the EmDash API.
  • How do you query content in an EmDash theme?You query content using the getCollection function from the @emdash-cms/astro integration inside Astro frontmatter.
  • What is the MCP server used for in EmDash?The MCP server allows AI coding agents to generate new components or adapt existing ones by exposing the theme's content schema and component library.
Share This Article