How to Optimize EmDash CMS Sites for SEO

How to Optimize EmDash CMS Sites for SEO

EmDash builds several SEO fundamentals in as platform behavior rather than something you bolt on — sitemap generation, robots.txt, and JSON-LD structured data are automatic. The catch is that all of them depend on one configuration value being set correctly, and it's easy to miss since your local dev server works fine without it.

Table of Contents
  1. The Setting That Breaks Everything If You Skip It
  2. Site-Wide SEO Defaults
  3. Per-Page Metadata: The page:metadata Hook
  4. What Each Metadata Kind Actually Renders
  5. A Realistic SEO Checklist for an EmDash Site
  6. Frequently Asked Questions
  7. Do I need a separate SEO plugin, the way WordPress relies on Yoast or Rank Math?
  8. How do I know if siteUrl is misconfigured right now?
  9. Can I set different JSON-LD schema types for different content types?
  10. Does EmDash handle image optimization for SEO (responsive sizes, lazy loading)?
  11. The Bottom Line

The Setting That Breaks Everything If You Skip It

siteUrl is the public browser-facing origin for the site. Behind a TLS-terminating reverse proxy, Astro.url returns the internal address instead of the public one. This breaks passkeys, CSRF origin matching, OAuth redirects, login redirects, MCP discovery, snapshot exports, sitemap, robots.txt, and JSON-LD structured data. Set siteUrl to fix all of these at once.

If your production deployment sits behind a reverse proxy or load balancer (genuinely common — Cloudflare, most managed Node hosts, most PaaS platforms), EmDash's internal view of its own URL can silently be `http://localhost:4321` or an internal container address instead of your real public domain. Every generated sitemap URL, every canonical tag, every JSON-LD `url` field inherits that mistake. Set it explicitly:

emdash({
  database: sqlite({ url: "file:./data.db" }),
  storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }),
  siteUrl: "https://your-real-domain.com",
});
emdashkits.com

Or set it via environment variable, useful for container deployments where the public URL is only known at runtime: `EMDASH_SITE_URL` or `SITE_URL`. This is the single highest-leverage SEO check for an EmDash site — verify it before anything else.

Site-Wide SEO Defaults

Under Settings in the admin panel (or via the MCP server / REST API), EmDash exposes a dedicated `seo` settings object:

  • titleSeparator — the character between page title and site title (e.g. " | ").
  • defaultOgImage — the fallback Open Graph image used when a specific piece of content has none set.
  • robotsTxt — custom robots.txt body; omit to use EmDash's generated default.
  • googleVerification — Google Search Console verification token.
  • bingVerification — Bing Webmaster Tools verification token.

Updating these via the AI assistant, as a real documented example:

"Set the default OG image to the new banner" or "Update the title separator to a vertical bar."
Read also:

Per-Page Metadata: The page:metadata Hook

For anything beyond the site-wide defaults — a custom meta description on a specific page, per-post JSON-LD structured data, a noindex directive on a specific page type — EmDash's plugin system exposes a `page:metadata` hook. It's available to sandboxed plugins (no native plugin required) and covers meta tags, OpenGraph properties, an allowlisted set of `<link>` rels, and JSON-LD:

"page:metadata": async (event, ctx) => {
  if (event.page.kind !== "content") return null;

  return {
    kind: "jsonld",
    id: `schema:${event.page.content?.collection}:${event.page.content?.id}`,
    graph: {
      "@context": "https://schema.org",
      "@type": "BlogPosting",
      headline: event.page.pageTitle ?? event.page.title,
      description: event.page.description,
    },
  };
},
emdashkits.com

Your templates opt into rendering these contributions by including `<EmDashHead>` (from `emdash/ui`) in your layout — core handles validation and deduplication automatically, so multiple plugins contributing metadata for the same page don't produce duplicate tags.

What Each Metadata Kind Actually Renders

  • meta — <meta name="..." content="..."> — descriptions, robots directives, Twitter cards.
  • property — <meta property="..." content="..."> — OpenGraph and similar.
  • link — <link rel="canonical|alternate|..." href="..."> — canonical URLs, hreflang alternates.
  • jsonld — <script type="application/ld+json"> — structured data graphs.

The `link` rel is deliberately restricted to a security-locked allowlist — `canonical`, `alternate`, `author`, `license`, `nlweb`, `site.standard.document` — so a plugin can't inject a `stylesheet` or other resource-loading link through this path; that's intentionally reserved for native plugins with `page:fragments`, a separate, more privileged hook.

A Realistic SEO Checklist for an EmDash Site

  • Set siteUrl explicitly in production — verify it matches your real public domain, not an internal address.
  • Fill in the site-wide seo settings object (title separator, default OG image, search console verification tokens).
  • Confirm the auto-generated sitemap and robots.txt are reachable at their expected URLs post-deploy.
  • Add page:metadata contributions for content-type-specific JSON-LD (Article/BlogPosting schema for posts, Person schema for author pages, BreadcrumbList for hierarchical content).
  • Set per-entry alt text on every image field — EmDash's image field type stores it, but it's only useful if editors actually fill it in.
  • Verify canonical tags render correctly on any URL that's reachable more than one way (pagination, filtered views, tag/category archives).

Frequently Asked Questions

Do I need a separate SEO plugin, the way WordPress relies on Yoast or Rank Math?

Not for the fundamentals — sitemap, robots.txt, and site-wide SEO settings are built into EmDash core. You'd reach for the `page:metadata` hook (in a first-party or third-party plugin) for anything more specialized, but there's no dependency on an equivalent to WordPress's SEO plugin ecosystem for baseline coverage.

How do I know if siteUrl is misconfigured right now?

Check your live sitemap.xml and any page's JSON-LD output — if URLs point to localhost, an internal container address, or the wrong domain, siteUrl (or the SITE_URL / EMDASH_SITE_URL environment variable) needs to be set explicitly in your production deployment.

Can I set different JSON-LD schema types for different content types?

Yes — the `page:metadata` hook receives the current page's content type and collection in its event payload, so a single hook can branch and return BlogPosting schema for posts, Product schema for a products collection, and so on.

Does EmDash handle image optimization for SEO (responsive sizes, lazy loading)?

Yes, for images going through EmDash's own image rendering component — it generates responsive srcset output via Astro's image service where possible, with lazy loading and async decoding set by default.

The Bottom Line

Most of EmDash's SEO foundation is automatic — sitemap, robots.txt, and JSON-LD generation ship as platform behavior, not a plugin you have to install. The one thing that will quietly break all three if you miss it is `siteUrl` behind a reverse proxy, so verify that first. From there, the site-wide `seo` settings object and the `page:metadata` hook cover everything from search console verification to content-type-specific structured data. See our guide to customizing content models if you're still shaping the content types this metadata will describe.

Share

Comments

Write a comment

Related Articles

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

July 16, 2026

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

July 16, 2026

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

TL;DR — A custom session-cookie login flow appeared to succeed on localhost (the OTP verified, the response looked fine) but every subsequent request to a login-gated page treated the visitor as logged out. Identical code worked fine on the live HTTPS site. The cookie's Secure attribute was hardcoded to true — and per the cookie spec, browsers never store or send a Secure cookie over a plain, non-HTTPS connection.

Table of Contents
  1. Diagnostic
  2. Root cause
  3. Fix
  4. Lessons learned

Diagnostic

Check the actual Set-Cookie response header and the browser's own cookie storage panel — on localhost over http://, the cookie is sent by the server but never actually stored by the browser.

Root cause

// before -- assumes the app is always served over HTTPS
setCookie("session", token, { secure: true, httpOnly: true });
emdashkits.com

A cookie config that quietly assumes "we're always on HTTPS" breaks the instant you test over plain HTTP, which local dev servers commonly are.

Read also:

Fix

// after -- derive secure from the actual request protocol
const isHttps = request.url.startsWith("https://");
setCookie("session", token, { secure: isHttps, httpOnly: true });
emdashkits.com

Lessons learned

  • Any Secure-flagged cookie needs to key off the real request scheme, not an assumption baked in once at cookie-creation time.
  • "Works in production, silently fails in local dev" is a strong signal to check cookie flags before anything else in an auth flow.
  • Check other cookies in the same codebase for the same hardcoded assumption — if one cookie has this bug, sibling cookies set the same way are worth auditing too.
Share
Previous Article

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Comments

Write a comment

Related Articles

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

July 16, 2026

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

July 16, 2026

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

TL;DR — GA4's gtag.js snippet was installed correctly, the page loaded with no visible JavaScript error, and GA4's real-time report still showed zero activity. The CMS's default Content-Security-Policy had no allowance for analytics domains and no config option to add one — so every request to Google's tracking endpoints was blocked at the browser level before it could fail loudly.

Table of Contents
  1. Diagnostic
  2. Root cause
  3. Lessons learned

Diagnostic

Check the browser's dedicated CSP violation reporting, not the regular console error list — CSP blocks are reported through their own channel, not thrown as normal script errors, so "no console errors" doesn't mean nothing was blocked.

Root cause

The CSP's script-src and connect-src directives had no entry for googletagmanager.com or google-analytics.com, and the CMS exposed no configuration surface to add one — the only way in was patching the CSP directives directly.

// patch-package: add analytics domains to the existing CSP directives
scriptSrc.push("https://www.googletagmanager.com");
connectSrc.push("https://www.google-analytics.com", "https://www.googletagmanager.com");
emdashkits.com
Read also:

Lessons learned

  • "No console errors" is not proof nothing was blocked — CSP violations live in their own reporting surface and are easy to miss if you're only scanning for red error text.
  • Before adding any third-party script tag to a site with a CSP already in place, check the CSP's directives first rather than assuming a silently-empty analytics dashboard means a snippet-installation mistake.
Share
Previous Article

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Next Article

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Comments

Write a comment

Related Articles

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

July 16, 2026

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

July 16, 2026

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)