{"id":97965,"date":"2026-09-30T12:38:00","date_gmt":"2026-09-30T16:38:00","guid":{"rendered":"https:\/\/overcentral.com\/en\/?p=97965"},"modified":"2026-09-29T08:11:20","modified_gmt":"2026-09-29T12:11:20","slug":"custom-emdash-theme-astro-tailwind-97965","status":"publish","type":"post","link":"https:\/\/overcentral.com\/en\/custom-emdash-theme-astro-tailwind-97965\/","title":{"rendered":"How to Build a Custom EmDash Theme Using Astro and Tailwind"},"content":{"rendered":"<p><a href=\"https:\/\/emdash.com\" target=\"_blank\" rel=\"noopener noreferrer\" data-iacss-external=\"1\">EmDash<\/a> is not WordPress. You cannot drop a <code>functions.php<\/code>codecodecodecodecode file into a folder and call it a day. Themes in EmDash are full <a href=\"https:\/\/astro.build\" target=\"_blank\" rel=\"noopener noreferrer\" data-iacss-external=\"1\">Astro<\/a> projects with strict boundaries\u2014they 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.<\/p>\n<h2>Understanding the EmDash Theme Contract<\/h2>\n<p>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\u2014the plugin sandbox (run via Cloudflare Dynamic Workers) handles all backend logic. Your theme is purely a frontend rendering layer.<\/p>\n<p>The contract is simple: your theme provides Astro pages and components that query content via the EmDash <code>fetch<\/code>codecodecodecodecode API or using the built-in <code>@emdash-cms\/astro<\/code>codecodecodecodecode integration. That integration exposes content collections, portable text blocks, and media references.<\/p>\n<h2>File Structure That Works<\/h2>\n<p>EmDash expects a theme to follow Astro conventions with one addition: a <code>emdash.config.ts<\/code>codecodecodecodecode file at the project root. This file declares the theme name, version, and any dependencies. Here is a minimal example:<\/p>\n<p>&#8220;`ts<\/p>\n<p>\/\/ emdash.config.ts<\/p>\n<p>import { defineTheme } from &#8216;@emdash-cms\/astro&#8217;;<\/p>\n<p>export default defineTheme({<\/p>\n<p>  name: &#8216;my-custom-theme&#8217;,<\/p>\n<p>  version: &#8216;1.0.0&#8217;,<\/p>\n<p>  collections: [&#8216;posts&#8217;, &#8216;projects&#8217;],<\/p>\n<p>});<\/p>\n<p>&#8220;`<\/p>\n<p>The <code>collections<\/code>codecodecodecodecode 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\u2014otherwise your theme will not know to query it.<\/p>\n<p>Inside <code>src\/<\/code>codecodecodecodecode, structure your components by purpose:<\/p>\n<p>&#8220;`<\/p>\n<p>src\/<\/p>\n<p>  components\/<\/p>\n<p>    Header.astro<\/p>\n<p>    Footer.astro<\/p>\n<p>    PostCard.astro<\/p>\n<p>  layouts\/<\/p>\n<p>    BaseLayout.astro<\/p>\n<p>  pages\/<\/p>\n<p>    index.astro<\/p>\n<p>    posts\/[slug].astro<\/p>\n<p>  styles\/<\/p>\n<p>    global.css<\/p>\n<p>&#8220;`<\/p>\n<p>EmDash treats <code>src\/pages\/<\/code>codecodecodecodecode as the routing layer. The <code>[slug]<\/code>codecodecodecodecode parameter corresponds to the content item\u2019s slug. You query content inside the Astro frontmatter using the EmDash client.<\/p>\n<h2>Querying Content in Astro Frontmatter<\/h2>\n<p>EmDash provides a <code>getCollection<\/code>codecodecodecodecode function that works similarly to Astro\u2019s own content collections but connects to the EmDash API. Example for a blog post page:<\/p>\n<p>&#8220;`astro<\/p>\n<p>\/\/ src\/pages\/posts\/[slug].astro<\/p>\n<p>import { getCollection } from &#8216;@emdash-cms\/astro&#8217;;<\/p>\n<p>import BaseLayout from &#8216;..\/..\/layouts\/BaseLayout.astro&#8217;;<\/p>\n<p>export async function getStaticPaths() {<\/p>\n<p>  const posts = await getCollection(&#8216;posts&#8217;);<\/p>\n<p>  return posts.map(post =&gt; ({<\/p>\n<p>    params: { slug: post.slug },<\/p>\n<p>    props: { post },<\/p>\n<p>  }));<\/p>\n<p>}<\/p>\n<p>const { post } = Astro.props;<\/p>\n<article>\n<p><h1>{post.title}<\/h1>\n<\/p>\n<\/article>\n<\/p>\n<p>&#8220;`<\/p>\n<p>The <code>getCollection<\/code>codecodecodecodecode 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 <code>output<\/code>codecodecodecodecode setting in <code>astro.config.mjs<\/code>codecodecodecodecode. For most EmDash sites, you want <code>output: 'hybrid'<\/code>codecodecodecodecode\u2014static for most pages, server-rendered for pages that need live preview or draft content.<\/p>\n<h2>Tailwind Integration Without the Bloat<\/h2>\n<p>EmDash themes ship with Tailwind support out of the box, but the default configuration includes every utility. For a production theme, prune aggressively.<\/p>\n<p>Create <code>tailwind.config.ts<\/code>codecodecodecodecode and set <code>content<\/code>codecodecodecodecode to only scan your <code>src\/<\/code>codecodecodecodecode directory:<\/p>\n<p>&#8220;`ts<\/p>\n<p>export default {<\/p>\n<p>  content: [&#8216;.\/src\/<em>*\/<\/em>.{astro,html,ts,tsx}&#8217;],<\/p>\n<p>  theme: {<\/p>\n<p>    extend: {<\/p>\n<p>      colors: {<\/p>\n<p>        primary: &#8216;#3b82f6&#8217;,<\/p>\n<p>      },<\/p>\n<p>    },<\/p>\n<p>  },<\/p>\n<p>};<\/p>\n<p>&#8220;`<\/p>\n<p>Then in your <code>BaseLayout.astro<\/code>codecodecodecodecode, import the generated CSS:<\/p>\n<p>&#8220;`astro<\/p>\n<p>import &#8216;..\/styles\/global.css&#8217;;<\/p>\n<p>&#8220;`<\/p>\n<p>That <code>global.css<\/code>codecodecodecodecode should contain only the Tailwind directives and any custom base styles:<\/p>\n<p>&#8220;`css<\/p>\n<p>@tailwind base;<\/p>\n<p>@tailwind components;<\/p>\n<p>@tailwind utilities;<\/p>\n<p>body {<\/p>\n<p>  @apply bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100;<\/p>\n<p>}<\/p>\n<p>&#8220;`<\/p>\n<p>The non-obvious part: EmDash themes support dark mode through a CSS class on the <code><\/code>codecodecodecodecode element. The EmDash admin panel toggles this class. Your theme must respond. Use Tailwind\u2019s <code>dark:<\/code>codecodecodecodecode variant and ensure your layout reads the class from the request\u2014Astro\u2019s <code>Astro.request.headers<\/code>codecodecodecodecode gives you the cookie that stores the preference. Alternatively, use <code>@media (prefers-color-scheme: dark)<\/code>codecodecodecodecode if you want system-level detection.<\/p>\n<h2>Handling Portable Text<\/h2>\n<p>EmDash stores rich content as portable text\u2014a structured JSON format similar to Sanity\u2019s. You cannot just output <code>{post.content}<\/code>codecodecodecodecode. You need a component that renders the blocks.<\/p>\n<p>EmDash ships a <code>PortableText<\/code>codecodecodecodecode component in <code>@emdash-cms\/astro<\/code>codecodecodecodecode:<\/p>\n<p>&#8220;`astro<\/p>\n<p>import { PortableText } from &#8216;@emdash-cms\/astro&#8217;;<\/p>\n<p>import type { PortableTextBlock } from &#8216;@emdash-cms\/astro&#8217;;<\/p>\n<p>interface Props {<\/p>\n<p>  content: PortableTextBlock[];<\/p>\n<p>}<\/p>\n<p>const { content } = Astro.props;<\/p>\n<p>&#8220;`<\/p>\n<p>By default, it renders standard blocks (paragraph, heading, image, list). To customize a block type, pass a <code>components<\/code>codecodecodecodecode object:<\/p>\n<p>&#8220;`astro<\/p>\n<p>&lt;PortableText<\/p>\n<p>  content={content}<\/p>\n<p>  components={{<\/p>\n<p>    block: {<\/p>\n<p>      h1: ({ children }) =&gt; <\/p>\n<h1 class=\"text-4xl font-bold\">{children}<\/h1>\n<p>,<\/p>\n<p>      image: ({ value }) =&gt; <img decoding=\"async\" src=\"{value.url}\" alt=\"{value.alt}\" class=\"rounded-lg\" \/>,<\/p>\n<p>    },<\/p>\n<p>  }}<\/p>\n<p>\/&gt;<\/p>\n<p>&#8220;`<\/p>\n<p>This is where Tailwind shines\u2014you apply utility classes directly in the component map without writing separate CSS.<\/p>\n<h2>Adding Plugin Sandbox Support (Theme Side)<\/h2>\n<p>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 &#8220;related posts&#8221; section might emit a <code>emdash:related-posts<\/code>codecodecodecodecode event. In your Astro component, you can subscribe:<\/p>\n<p>&#8220;`astro<\/p>\n<p>import { onEvent } from &#8216;@emdash-cms\/astro&#8217;;<\/p>\n<p>const relatedPosts = await onEvent(&#8217;emdash:related-posts&#8217;, { postSlug: post.slug });<\/p>\n<p>{relatedPosts.length &gt; 0 &amp;&amp; (<\/p>\n<section>\n<p><h2>Related<\/h2>\n<ul>\n<p>      {relatedPosts.map(rp =&gt; <\/p>\n<li>&lt;a href={<code>\/posts\/${rp.slug}<\/code>codecodecodecodecode}&gt;{rp.title}<\/a><\/li>\n<p>)}<\/p>\n<\/ul>\n<\/section>\n<p>)}<\/p>\n<p>&#8220;`<\/p>\n<p>This keeps your theme decoupled from plugin logic. If the plugin is not installed, <code>onEvent<\/code>codecodecodecodecode returns an empty array\u2014your page <a href=\"https:\/\/overcentral.com\/en\/rascal-does-not-dream-trailer-release-80139\/\" title=\"Rascal Does Not Dream Drops Trailer for Final Film\" data-iacss-internal=\"1\">does not<\/a> break.<\/p>\n<h2>Deployment: Cloudflare Workers vs Self-Hosted<\/h2>\n<p>EmDash themes deploy as Astro sites. On Cloudflare, you use the <code>@astrojs\/cloudflare<\/code>codecodecodecodecode adapter. The key configuration in <code>astro.config.mjs<\/code>codecodecodecodecode:<\/p>\n<p>&#8220;`ts<\/p>\n<p>import { defineConfig } from &#8216;astro\/config&#8217;;<\/p>\n<p>import cloudflare from &#8216;@astrojs\/cloudflare&#8217;;<\/p>\n<p>import tailwind from &#8216;@astrojs\/tailwind&#8217;;<\/p>\n<p>export default defineConfig({<\/p>\n<p>  adapter: cloudflare(),<\/p>\n<p>  integrations: [tailwind()],<\/p>\n<p>  output: &#8216;hybrid&#8217;,<\/p>\n<p>});<\/p>\n<p>&#8220;`<\/p>\n<p><a href=\"https:\/\/overcentral.com\/en\/eu-cra-reporting-requirements-80362\/\" title=\"EU CRA Demands What Shipped and When You Knew\" data-iacss-internal=\"1\">When you<\/a> 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\u2014Cloudflare Workers run on the V8 runtime. Use <code>@emdash-cms\/astro<\/code>codecodecodecodecode which abstracts the runtime differences.<\/p>\n<p>If you self-host on a Node.js server, use the <code>@astrojs\/node<\/code>codecodecodecodecode 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.<\/p>\n<h2>Performance: SSG for Content, SSR for Live Preview<\/h2>\n<p>The common mistake is rendering everything with SSR. EmDash content is mostly static\u2014blog posts, pages, projects. Use SSG for those. Only enable SSR for pages that need live preview (draft mode) or user-specific data. In your <code>[slug].astro<\/code>codecodecodecodecode page, check if the request has a preview token:<\/p>\n<p>&#8220;`astro<\/p>\n<p>const isPreview = Astro.request.headers.get(&#8216;x-preview-token&#8217;) === import.meta.env.PREVIEW_TOKEN;<\/p>\n<p>&#8220;`<\/p>\n<p>If <code>isPreview<\/code>codecodecodecodecode, force SSR by returning a <code>Response<\/code>codecodecodecodecode from the page. Otherwise, let Astro build it statically. This hybrid approach keeps your site fast while allowing editors to preview changes.<\/p>\n<p>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.<\/p>\n<h2>Advanced: Using the MCP Server for Theme Generation<\/h2>\n<p>EmDash ships a built-in MCP server (Model Context Protocol). You can point <a href=\"https:\/\/overcentral.com\/en\/ai-avatar-empire-96590\/\" title=\"Build an AI Avatar Empire Without Showing Your Face\" data-iacss-internal=\"1\">an AI<\/a> 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\u2019s content schema and component library, so the agent can write code that matches your design system. This is not a gimmick\u2014it reduces the time to create a new page type from hours to minutes.<\/p>\n<p>To enable it, install the MCP client in your editor and connect to <code>http:\/\/localhost:4321\/mcp<\/code>codecodecodecodecode. Then prompt the agent: &#8220;Create a new Astro page that lists all projects with a grid layout using Tailwind classes from my existing design tokens.&#8221; The agent reads your <code>tailwind.config.ts<\/code>codecodecodecodecode and generates code consistent with your theme.<\/p>\n<h2>The One Thing That Kills Themes<\/h2>\n<p>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:<\/p>\n<p>&#8220;`astro<\/p>\n<p>{post.customField?.value ?? &#8216;Default&#8217;}<\/p>\n<p>&#8220;`<\/p>\n<p>And in your <code>getStaticPaths<\/code>codecodecodecodecode, do not assume every item has the same fields. Check for existence before rendering. This defensive approach prevents 404s when the schema evolves.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>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\u2014they 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 [&hellip;]<\/p>\n","protected":false},"author":7,"featured_media":98664,"comment_status":"closed","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"fifu_image_url":"https:\/\/cards.overcentral.com\/cards\/en\/97965.png","fifu_image_alt":"How to Build a Custom EmDash Theme Using Astro and Tailwind","footnotes":""},"categories":[31],"tags":[],"class_list":["post-97965","post","type-post","status-publish","format-standard","has-post-thumbnail","category-technology"],"fifu_image_url":"https:\/\/cards.overcentral.com\/cards\/en\/97965.png","fifu_image_alt":"How to Build a Custom EmDash Theme Using Astro and Tailwind","_links":{"self":[{"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/posts\/97965","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/users\/7"}],"replies":[{"embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/comments?post=97965"}],"version-history":[{"count":1,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/posts\/97965\/revisions"}],"predecessor-version":[{"id":98508,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/posts\/97965\/revisions\/98508"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/media\/98664"}],"wp:attachment":[{"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/media?parent=97965"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/categories?post=97965"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/tags?post=97965"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}