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
- The setup
- Symptoms
- First: rule out bad data
- Diagnosis: find who builds the href
- Root cause: entry.id is a route ID, not a slug
- The fix: one URL helper, used everywhere
- Same root cause, more casualties
- Do you need redirects for the bad URLs?
- Bonus gotcha from the same session: this very article refused to save
- Lessons learned
- Affected setup
The setup
- Astro with
output: "server", EmDash CMS as the content layer - Two locales:
en(default, served at/{slug}) andid(served at/id/{slug}) - Post routes:
src/pages/[slug].astroandsrc/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
- Every Indonesian post card on the
/idhomepage linked to/id/id/{slug}. - Same doubled URL in the blog listing, the "Related Articles" grid, previous/next navigation, and the "Latest Articles" sidebar.
- The doubled URL returned 404 — every internal click on a translated post card was a dead end.
- Typing the correct URL
/id/{slug}by hand rendered the post fine. - 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.
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 rowsClean. 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 pagesFor 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;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;
}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/postsJSON endpoint — it now returns a ready-madeurlfield, 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 FoundIn 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
Serverresponse 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
- Treat
entry.idas an opaque route identifier. Public URLs come fromdata.locale+data.slug— fields whose meaning doesn't change when configuration does. - 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. - 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.
- 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.




Comments