EmDash + Astro i18n: Post Links Double the Locale Prefix (/id/id/) and 404 — Root Cause and Fix

EmDash + Astro i18n: Post Links Double the Locale Prefix (/id/id/) and 404 — Root Cause and Fix

TL;DR — On a bilingual (English + Indonesian) EmDash + Astro site, every link to an Indonesian post — from the homepage feed, the blog listing, related articles, and the sidebar — pointed to /id/id/{slug} instead of /id/{slug}, and 404'd. The slugs in the database were clean. The real cause: with i18n enabled, the CMS returns entry.id already prefixed with the locale ("id/some-slug"), and our templates prepended the locale base path on top of it. The fix is one small helper that builds post URLs from data.locale + data.slug, used everywhere a post link is rendered.

Table of Contents
  1. The setup
  2. Symptoms
  3. First: rule out bad data
  4. Diagnosis: find who builds the href
  5. Root cause: entry.id is a route ID, not a slug
  6. The fix: one URL helper, used everywhere
  7. Same root cause, more casualties
  8. Do you need redirects for the bad URLs?
  9. Bonus gotcha from the same session: this very article refused to save
  10. Lessons learned
  11. Affected setup

The setup

  • Astro with output: "server", EmDash CMS as the content layer
  • Two locales: en (default, served at /{slug}) and id (served at /id/{slug})
  • Post routes: src/pages/[slug].astro and src/pages/id/[slug].astro
  • Every list surface (home feed, blog grid, related posts, sidebar) renders links from getEmDashCollection() entries

If you run a single-locale site, this bug cannot hit you — which is exactly why it shipped: everything worked perfectly until the second locale went live. The stack itself is the same one described in our EmDash + Astro setup guide.

Symptoms

  1. Every Indonesian post card on the /id homepage linked to /id/id/{slug}.
  2. Same doubled URL in the blog listing, the "Related Articles" grid, previous/next navigation, and the "Latest Articles" sidebar.
  3. The doubled URL returned 404 — every internal click on a translated post card was a dead end.
  4. Typing the correct URL /id/{slug} by hand rendered the post fine.
  5. English pages were completely unaffected.

That combination — the page exists, the generated links are wrong, and only one locale is affected — already tells you the bug lives in link generation, not in routing and not in the content itself.

Read also:

First: rule out bad data

When a URL segment doubles, the first suspect is the slug itself: did someone (or some migration script) save the slug as id/litertjs-... in the CMS? Thirty seconds of SQL settles it:

SELECT slug, locale FROM ec_posts WHERE slug LIKE '%/%';
-- 0 rows
emdashkits.com

Clean. The slug is fine, so the doubling happens at render time. (The same discipline saved us before — check the data before you blame the data.)

Diagnosis: find who builds the href

Grep the templates for how post links are built. Every list surface had some variation of:

<a href={basePath + "/" + post.id}>{post.data.title}</a>
// basePath = "/id" on Indonesian pages
emdashkits.com

For the doubled URL to come out of this, post.id itself must already be "id/{slug}". The CMS loader confirms it — inside emdash's collection loader, the entry ID is constructed like this:

const entryId =
  i18nEnabled && entryLocale !== "" &&
  (entryLocale !== i18nConfig.defaultLocale || i18nConfig.prefixDefaultLocale)
    ? entryLocale + "/" + entrySlug
    : entrySlug;
emdashkits.com

There it is. Once more than one locale is configured, entry.id for a non-default-locale entry is "{locale}/{slug}". So the template computed "/id" + "/" + "id/litertjs-..." — and the double prefix was born.

Root cause: entry.id is a route ID, not a slug

This is not a CMS bug — it's a contract misunderstanding. On a single-locale site, entry.id happens to equal the slug, so `"/" + entry.id` works and every template gets written that way. The moment i18n goes live, the ID silently changes shape to carry the locale, and every `basePath + "/" + entry.id` in the codebase becomes wrong at once.

There's a subtle trap hiding in the same behavior: `"/" + entry.id` with no base path stays accidentally correct (it yields /id/{slug}). Some of our pages — mixed-locale tag archives, saved-posts lists — kept working for exactly that reason, which made the bug look inconsistent and harder to pin down.

The fix: one URL helper, used everywhere

Stop deriving URLs from entry.id entirely. Build them from fields whose meaning can't drift:

// src/lib/post-path.ts
export function postPath(post: {
  id: string;
  data?: { slug?: string | null; locale?: string | null };
}): string {
  const slug = post.data?.slug;
  if (!slug) return "/" + post.id; // entry.id already carries the prefix
  const locale = post.data?.locale;
  return !locale || locale === "en" ? "/" + slug : "/" + locale + "/" + slug;
}
emdashkits.com

Then replace every hand-rolled concatenation. On our site that meant:

  • Homepage feed — both the server-rendered cards and the infinite-scroll cards
  • Blog listing and the shared post-card grid
  • Related articles, previous/next navigation, and the "read also" box
  • The "Latest Articles" sidebar
  • The /api/posts JSON endpoint — it now returns a ready-made url field, so client-side scripts stop rebuilding URLs from raw IDs
  • Saved posts on the profile page, and search results

The API point deserves emphasis: infinite-scroll scripts re-render cards in client-side JavaScript, so they can't call a server helper. Returning the final URL from the API keeps the URL logic in exactly one place.

Same root cause, more casualties

Once you know the ID changed shape, audit everything that consumed it. We found three more breakages hiding behind the visible one:

  • Share URLs and JSON-LD — the article page built its canonical page URL from entry.id, so structured data and share intents carried the doubled URL too.
  • The save-post button — it sent entry.id ("id/slug") into /api/saved-posts/[id], a single-segment route. The slash made the request 404: saving translated posts had been silently broken the whole time.
  • Search results — the opposite symptom: links built from the bare slug with no locale prefix, sending Indonesian results to /{slug}. Different direction, same disease: a URL assembled from the wrong field.

Do you need redirects for the bad URLs?

Check what the doubled URL actually returns before reaching for redirect rules:

curl -I https://your-site.com/id/id/some-slug
# HTTP/1.1 404 Not Found
emdashkits.com

In our case the /id/[slug] route only matches a single path segment, so the doubled URLs always 404'd — they never returned 200, so search engines had nothing to index and fixing the links is enough. If your routing somehow serves content on both URLs, pick the canonical one and 301 the other — EmDash's redirect manager handles that in a couple of clicks.

Bonus gotcha from the same session: this very article refused to save

There's a punchline. When we loaded this post into the CMS and tried to attach a featured image, every save failed with a plain-text 403 Forbidden. Nothing was wrong with the CMS, and nothing was wrong with permissions: the site sits behind a CDN whose Web Application Firewall scores every request body for injection-looking patterns. A technical article is exactly that. The code samples above are full of placeholder syntax and shell one-liners, and every save ships the entire article body along with whatever field you changed. Each snippet adds a few points to the anomaly score; enough snippets in one request and the firewall blocks it at the edge — before it ever reaches the CMS.

How to recognize this variant of "save keeps failing":

  • The response is plain text (just the word "Forbidden") instead of your CMS's JSON error shape — and the Server response header names the CDN, not your app.
  • The same post saves fine with trivial content, and other posts save fine — only content-heavy saves die.
  • Replaying the exact save payload without a session cookie returns 403 instead of 401 — proof the block happens before authentication is even checked, i.e. in front of your app.

Two ways out: allow your CMS admin path through the WAF (the durable fix), or write code samples defensively — string concatenation instead of template literals, no pipes in shell examples. That, incidentally, is why the snippets in this article are written concatenation-style.

Lessons learned

  1. Treat entry.id as an opaque route identifier. Public URLs come from data.locale + data.slug — fields whose meaning doesn't change when configuration does.
  2. Centralize URL building. Our fix touched more than ten call sites because every template rolled its own string concatenation. With a postPath() helper from day one, this would have been a one-line fix.
  3. Rule out bad data with a thirty-second query before touching code. A doubled URL segment smells like a bad slug; prove it isn't, then move on with confidence.
  4. When you enable a second locale, walk the entire navigation of that locale — every list, every widget, every client-rendered card. Our English pages masked the bug completely, and "the site works" was only ever tested in English.

Affected setup

Observed on an EmDash + Astro site with i18n enabled (two locales, default locale unprefixed). Single-locale sites are structurally immune, since entry.id equals the slug there. The behavior itself matches how Astro's content layer namespaces localized entries, so the pattern — and the fix — applies to most multilingual content-collection setups, not just this stack.

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)