<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Issa Hadjidj</title>
    <description>The latest articles on DEV Community by Issa Hadjidj (@issa_hadjidj).</description>
    <link>https://dev.to/issa_hadjidj</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4081771%2F062d8ab0-ff0f-4a1d-8eae-f8cf8f444a86.jpeg</url>
      <title>DEV Community: Issa Hadjidj</title>
      <link>https://dev.to/issa_hadjidj</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/issa_hadjidj"/>
    <language>en</language>
    <item>
      <title>Your sitemap and your noindex tags disagree. Here's how to make that impossible.</title>
      <dc:creator>Issa Hadjidj</dc:creator>
      <pubDate>Mon, 17 Aug 2026 14:38:53 +0000</pubDate>
      <link>https://dev.to/issa_hadjidj/your-sitemap-and-your-noindex-tags-disagree-heres-how-to-make-that-impossible-3bpa</link>
      <guid>https://dev.to/issa_hadjidj/your-sitemap-and-your-noindex-tags-disagree-heres-how-to-make-that-impossible-3bpa</guid>
      <description>&lt;p&gt;I found a sitemap.xml sitting in the public/ folder of a site I work on. It declared three URLs. The site had seventeen pages.&lt;/p&gt;

&lt;p&gt;Fourteen pages had never been in the sitemap. Nobody wrote them out — they were simply added after someone typed that file by hand, and no one thought to go back.&lt;/p&gt;

&lt;p&gt;That's the normal fate of a hand-written sitemap. It is correct on the day you write it and wrong from the next commit onward.&lt;/p&gt;

&lt;p&gt;Generate it from the filesystem&lt;br&gt;
In Astro, any file in src/pages that isn't a .astro page can be an API route. A sitemap.xml.ts file becomes /sitemap.xml, and it runs at build time.&lt;/p&gt;

&lt;p&gt;import.meta.glob gives you every page file in the project, so the sitemap can read the same source of truth the router reads:&lt;/p&gt;

&lt;p&gt;interface Entry {&lt;br&gt;
  loc: string;&lt;br&gt;
  changefreq: 'weekly' | 'monthly' | 'yearly';&lt;br&gt;
  priority: string;&lt;br&gt;
  lastmod?: string;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;const DEFAULTS: Omit = { changefreq: 'monthly', priority: '0.5' };&lt;/p&gt;

&lt;p&gt;const SETTINGS: Record&amp;gt; = {&lt;br&gt;
  '/': { changefreq: 'weekly', priority: '1.0' },&lt;br&gt;
  '/blog/': { changefreq: 'weekly', priority: '0.8' },&lt;br&gt;
  '/legal-notice': { changefreq: 'yearly', priority: '0.1' },&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;const PAGES: Entry[] = Object.keys(import.meta.glob('./*&lt;em&gt;/&lt;/em&gt;.astro'))&lt;br&gt;
  .filter((path) =&amp;gt; !path.includes('['))       // dynamic routes handled below&lt;br&gt;
  .map((path) =&amp;gt; {&lt;br&gt;
    const route = path&lt;br&gt;
      .replace(/^./, '')&lt;br&gt;
      .replace(/\/index.astro$/, '/')          // blog/index.astro -&amp;gt; /blog/&lt;br&gt;
      .replace(/.astro$/, '');                 // about.astro      -&amp;gt; /about&lt;br&gt;
    const loc = route === '' ? '/' : route;&lt;br&gt;
    return { loc, ...(SETTINGS[loc] ?? DEFAULTS) };&lt;br&gt;
  });&lt;br&gt;
The important detail is the fallback. A page missing from SETTINGS still enters the sitemap, with default values. Forgetting to tune a page costs you an approximate priority — not its indexing. That inversion is the whole point: the failure mode has to be harmless, or you're back to maintaining a list by hand.&lt;/p&gt;

&lt;p&gt;Dynamic routes are filtered out because [slug].astro has no URL of its own. Its real URLs come from your content source:&lt;/p&gt;

&lt;p&gt;const posts = await getPosts();          // CMS, in our case Sanity&lt;/p&gt;

&lt;p&gt;const blogEntries = posts.map((post) =&amp;gt; ({&lt;br&gt;
  loc: &lt;code&gt;/blog/${post.slug}/&lt;/code&gt;,&lt;br&gt;
  changefreq: 'monthly' as const,&lt;br&gt;
  priority: '0.7',&lt;br&gt;
  lastmod: post.updatedAt ?? post.publishedAt,&lt;br&gt;
}));&lt;br&gt;
Add a page, publish an article: both land in the sitemap on the next build. Nobody has to remember anything.&lt;/p&gt;

&lt;p&gt;The part that actually matters&lt;br&gt;
Here's the bug that survives every "generate your sitemap automatically" tutorial.&lt;/p&gt;

&lt;p&gt;A sitemap is a request. You are telling Google: please index these URLs. A noindex tag is the opposite instruction: do not index this page.&lt;/p&gt;

&lt;p&gt;If a page carries noindex and appears in your sitemap, you are sending two contradictory instructions about the same URL. Google resolves it — noindex wins — but you've spent crawl budget asking for something you refuse, and Search Console will report the conflict back to you as an error you then have to triage.&lt;/p&gt;

&lt;p&gt;Automatic discovery makes this more likely, not less, because the glob knows nothing about your rendering logic.&lt;/p&gt;

&lt;p&gt;So the noindex condition has to be evaluated in the sitemap too, from the same source:&lt;/p&gt;

&lt;p&gt;// A draft or unpublished article is noindex — it must not be requested.&lt;br&gt;
const blogEntries = posts&lt;br&gt;
  .filter((post) =&amp;gt; !post.seo?.noindex)&lt;br&gt;
  .map((post) =&amp;gt; ({ /* … */ }));&lt;/p&gt;

&lt;p&gt;// A page behind a feature flag is noindex until the flag flips.&lt;br&gt;
let pages = FEATURE.seo.noindex&lt;br&gt;
  ? PAGES.filter((p) =&amp;gt; p.loc !== '/feature-page')&lt;br&gt;
  : PAGES;&lt;/p&gt;

&lt;p&gt;// A hub page with nothing published yet has nothing to show either.&lt;br&gt;
if (!items.some(isPublished)) {&lt;br&gt;
  pages = pages.filter((p) =&amp;gt; p.loc !== '/hub/');&lt;br&gt;
}&lt;br&gt;
Three different reasons for the same rule: if a page can go noindex, the sitemap has to know why.&lt;/p&gt;

&lt;p&gt;The alternative is a sitemap that drifts in the other direction — technically complete, semantically wrong, quietly asking Google to index pages that reject it.&lt;/p&gt;

&lt;p&gt;Give lastmod something true to say&lt;br&gt;
lastmod is only useful if it reflects a real change. A build timestamp on every URL is worse than no lastmod at all: it says everything changed, every deploy, which tells the crawler nothing.&lt;/p&gt;

&lt;p&gt;Take the date from the content:&lt;/p&gt;

&lt;p&gt;lastmod: post.updatedAt ?? post.publishedAt&lt;br&gt;
For pages that aggregate — an author page, a category index — the freshest thing they contain is the right answer:&lt;/p&gt;

&lt;p&gt;lastmod: author.posts[0]?.updatedAt ?? author.posts[0]?.publishedAt&lt;br&gt;
And when a page has no meaningful date, omit the field. It's optional, and an omitted lastmod is more honest than a fabricated one.&lt;/p&gt;

&lt;p&gt;const url = [&lt;br&gt;
  '\t',&lt;br&gt;
  &lt;code&gt;\t\t&amp;lt;loc&amp;gt;${SITE}${loc}&amp;lt;/loc&amp;gt;&lt;/code&gt;,&lt;br&gt;
  lastmod ? &lt;code&gt;\t\t&amp;lt;lastmod&amp;gt;${lastmod}&amp;lt;/lastmod&amp;gt;&lt;/code&gt; : null,&lt;br&gt;
  &lt;code&gt;\t\t&amp;lt;changefreq&amp;gt;${changefreq}&amp;lt;/changefreq&amp;gt;&lt;/code&gt;,&lt;br&gt;
  &lt;code&gt;\t\t&amp;lt;priority&amp;gt;${priority}&amp;lt;/priority&amp;gt;&lt;/code&gt;,&lt;br&gt;
  '\t',&lt;br&gt;
].filter(Boolean).join('\n');&lt;br&gt;
Returning it&lt;br&gt;
export const GET: APIRoute = async () =&amp;gt; {&lt;br&gt;
  // …build &lt;code&gt;urls&lt;/code&gt; from the entries above&lt;br&gt;
  return new Response(&lt;br&gt;
    &lt;code&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;\n&lt;/code&gt; +&lt;br&gt;
    &lt;code&gt;&amp;lt;urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"&amp;gt;\n${urls}\n&amp;lt;/urlset&amp;gt;\n&lt;/code&gt;,&lt;br&gt;
    { headers: { 'Content-Type': 'application/xml; charset=utf-8' } },&lt;br&gt;
  );&lt;br&gt;
};&lt;br&gt;
Delete the old public/sitemap.xml when you do this. A static file in public/ wins over a route with the same name, and you will spend an entertaining twenty minutes wondering why your generated sitemap still shows three URLs.&lt;/p&gt;

&lt;p&gt;What changed&lt;br&gt;
Seventeen pages in the sitemap instead of three. Two pages correctly absent from it, because they're noindex. New articles enter on publication, new pages on the next build.&lt;/p&gt;

&lt;p&gt;The maintenance cost is now zero, which is the only maintenance cost anyone actually pays.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>seo</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Your CMS migration killed URLs you don't know about. The Wayback Machine knows which ones.</title>
      <dc:creator>Issa Hadjidj</dc:creator>
      <pubDate>Mon, 17 Aug 2026 14:26:12 +0000</pubDate>
      <link>https://dev.to/issa_hadjidj/your-cms-migration-killed-urls-you-dont-know-about-the-wayback-machine-knows-which-ones-3dh1</link>
      <guid>https://dev.to/issa_hadjidj/your-cms-migration-killed-urls-you-dont-know-about-the-wayback-machine-knows-which-ones-3dh1</guid>
      <description>&lt;p&gt;We moved voxtrend.fr from Webflow to Astro. The redirects were written, the build was green, the site was live. Two months later I went looking for something else and found eleven URLs still sitting in Google's index, all returning 404.&lt;/p&gt;

&lt;p&gt;None of them were in the redirect map. Not because anyone was careless : because nobody remembered they existed.&lt;/p&gt;

&lt;p&gt;The failure mode&lt;br&gt;
When you migrate, you write redirects for the URLs you know about. You get them from the old CMS export, or from the sitemap, or from memory.&lt;/p&gt;

&lt;p&gt;That covers the pages you published. It misses:&lt;/p&gt;

&lt;p&gt;pages you deleted a year before the migration, which Google still has&lt;br&gt;
demo content from the CMS template you never cleaned up&lt;br&gt;
URL variants the old CMS generated on its own&lt;br&gt;
pages in a language or section you dropped along the way&lt;br&gt;
Every one of those may still hold external links. A 404 throws that away.&lt;/p&gt;

&lt;p&gt;The Wayback Machine has the list&lt;br&gt;
The Internet Archive exposes a CDX API that returns every URL it ever captured for a domain. No key, no account, no rate limit worth worrying about for a one-off.&lt;/p&gt;

&lt;p&gt;curl -s "&lt;a href="https://web.archive.org/cdx/search/cdx?url=voxtrend.fr&amp;amp;matchType=domain&amp;amp;output=json&amp;amp;fl=original&amp;amp;collapse=urlkey&amp;amp;filter=statuscode:200&amp;amp;limit=500" rel="noopener noreferrer"&gt;https://web.archive.org/cdx/search/cdx?url=voxtrend.fr&amp;amp;matchType=domain&amp;amp;output=json&amp;amp;fl=original&amp;amp;collapse=urlkey&amp;amp;filter=statuscode:200&amp;amp;limit=500&lt;/a&gt;" -o cdx.json&lt;br&gt;
The parameters that matter:&lt;/p&gt;

&lt;p&gt;matchType=domain — the whole domain, not just the exact URL&lt;br&gt;
collapse=urlkey — one row per URL instead of one per capture&lt;br&gt;
filter=statuscode:200 — only pages that actually resolved&lt;br&gt;
fl=original — return just the URL column&lt;br&gt;
Then reduce it to unique paths and drop the assets:&lt;/p&gt;

&lt;p&gt;import json, re, urllib.parse&lt;/p&gt;

&lt;p&gt;rows = json.load(open('cdx.json'))[1:]      # first row is the header&lt;br&gt;
paths = set()&lt;/p&gt;

&lt;p&gt;for (url,) in rows:&lt;br&gt;
    p = urllib.parse.urlparse(url).path.rstrip('/') or '/'&lt;br&gt;
    if re.search(r'.(png|jpe?g|svg|css|js|woff2?|ico|webp|pdf)$', p, re.I):&lt;br&gt;
        continue&lt;br&gt;
    paths.add(p)&lt;/p&gt;

&lt;p&gt;for p in sorted(paths):&lt;br&gt;
    print(p)&lt;br&gt;
What that turned up&lt;br&gt;
Fifty-two historical paths. Most were already redirected. Eleven were not, and they fell into two very different groups.&lt;/p&gt;

&lt;p&gt;Real pages, still linked from outside:&lt;/p&gt;

&lt;p&gt;/about-us&lt;br&gt;
/politique-de-confidentialite&lt;br&gt;
/privacy-policy&lt;br&gt;
/blog-2&lt;br&gt;
/connexion&lt;br&gt;
/creation_de_compte&lt;br&gt;
Leftovers from the Webflow starter template:&lt;/p&gt;

&lt;p&gt;/blog-post/best-prototyping-tools-12&lt;br&gt;
/blog-post/best-prototyping-tools-13&lt;br&gt;
/career-post/interface-designer-2&lt;br&gt;
/integrations-post/blossom&lt;br&gt;
/blog-categories/banking&lt;br&gt;
Someone had launched from a template and never deleted the demo content. It sat there long enough to get crawled.&lt;/p&gt;

&lt;p&gt;Check which ones are still live before you write anything:&lt;/p&gt;

&lt;p&gt;for p in $(cat paths.txt); do&lt;br&gt;
  printf "%-44s " "$p"&lt;br&gt;
  curl -s -o /dev/null -w "%{http_code}\n" "&lt;a href="https://voxtrend.fr$p" rel="noopener noreferrer"&gt;https://voxtrend.fr$p&lt;/a&gt;"&lt;br&gt;
done&lt;br&gt;
Redirect the first group. Let the second one 404.&lt;br&gt;
This is the part people get wrong, and it's counter-intuitive.&lt;/p&gt;

&lt;p&gt;For the real pages, redirect to the closest equivalent. /about-us to the homepage if there's no about page anymore. /privacy-policy to the French version. Those pages existed, people linked to them, the link should land somewhere meaningful.&lt;/p&gt;

&lt;p&gt;For the template junk, do nothing. Let it 404.&lt;/p&gt;

&lt;p&gt;Bulk-redirecting a hundred dead URLs to your homepage is a documented soft-404 pattern. Google treats a redirect to an irrelevant page the same as a 404, and you've added a hop for nothing. A page that never had real content has nothing to preserve. A clean 404 is the correct answer.&lt;/p&gt;

&lt;p&gt;Two things I checked while I was in there&lt;br&gt;
Vercel's permanent: true emits a 308, not a 301. If your SEO tool flags "308 instead of 301", ignore it : Google treats them identically. Both are permanent, both pass signals. The difference is that 308 preserves the HTTP method, which is irrelevant for page redirects.&lt;/p&gt;

&lt;p&gt;The canonical host was inconsistent. astro.config.mjs declared site: '&lt;a href="https://voxtrend.fr" rel="noopener noreferrer"&gt;https://voxtrend.fr&lt;/a&gt;' while every page and the sitemap used &lt;a href="https://www.voxtrend.fr" rel="noopener noreferrer"&gt;https://www.voxtrend.fr&lt;/a&gt;. Nothing was broken, because nothing read Astro.site yet. The day you add @astrojs/sitemap or an RSS feed, they emit non-www URLs that all bounce through a redirect. Worth fixing before it becomes a real bug.&lt;/p&gt;

&lt;p&gt;The whole thing takes twenty minutes&lt;br&gt;
One API call, one script, one loop of status checks. It found six real URLs that had been quietly discarding whatever external links pointed at them.&lt;/p&gt;

&lt;p&gt;If you migrated a site in the last year and only redirected what was in the CMS export, run the CDX query. The list is longer than you think.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>astro</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
