Hydrogen Sitemap: Fix the Links the Starter Gets Wrong

A Hydrogen sitemap comes from two utilities in @shopify/hydrogen: getSitemapIndex writes /sitemap.xml and getSitemap writes each child sitemap, both reading the Storefront API sitemap query. The utilities are sound. The starter’s getLink is the weak part: it builds article, metaobject and locale URLs that your routes do not serve.

A sitemap is a list of URLs you are asking crawlers to fetch, so every wrong path is a 404 you submitted yourself. Everything below was checked against @shopify/[email protected] and the starter from @shopify/[email protected], with queries run against Storefront API 2026-04. One version note first: as of October 2026 the npm latest tag for @shopify/hydrogen is 2026.10.x, the new framework-agnostic SDK. If you are on the React Router based Hydrogen these routes belong to, pin the 2026-04 line.

What does the Hydrogen starter sitemap actually generate?

The starter ships three files: app/routes/[sitemap.xml].tsx, app/routes/sitemap.$type.$page[.xml].tsx and app/routes/[robots.txt].tsx, which already prints a Sitemap: line pointing at /sitemap.xml.

The index route calls getSitemapIndex({storefront, request}). Reading the 2026.4.8 source, that runs one aliased query asking for pagesCount on six SitemapType values (PRODUCT, COLLECTION, PAGE, BLOG, ARTICLE, METAOBJECT) and writes one <sitemap> entry per page, shaped /sitemap/products/1.xml. The base URL is new URL(request.url).origin, so a preview deployment’s sitemap lists preview URLs. The default types covers all six, including metaObjects.

Each child page calls getSitemap, which reads params.type and params.page, fetches sitemap(type: ...) { resources(page: $page) { items { handle updatedAt } } }, and hands every item to your getLink. It writes <loc>, <lastmod> from updatedAt, a hard-coded <changefreq>weekly</changefreq> unless you pass getChangeFreq, and one xhtml:link alternate per entry in locales. An unknown type throws a 404. An empty page falls back to a single link to / (the noItemsFallback option), which Hydrogen’s type comment says prevents errors in Search Console.

Shopify’s reference notes that a page holds anywhere from 0 to 250 items and that an empty page does not mean the end of the results. Both utilities set Cache-Control: max-age=86400 themselves, and the starter sets it again.

Skip getChangeFreq. Google’s sitemap guidance says it ignores <changefreq> and <priority>, and only uses <lastmod> when it is consistently accurate.

Why do the starter’s article links 404?

The starter’s getLink is the one from Shopify’s docs example:

getLink: ({type, baseUrl, handle, locale}) => {
  if (!locale) return `${baseUrl}/${type}/${handle}`;
  return `${baseUrl}/${locale}/${type}/${handle}`;
},

Products, collections and pages come out right, because the route segment matches the type name. Articles do not. Running the starter configuration against Shopify’s hydrogen-preview demo store gives https://shop.example.com/articles/10-tips-for-better-snowboarding, and the starter has no articles route. Its article route is blogs.$blogHandle.$articleHandle.tsx, so the real URL is /blogs/journal/10-tips-for-better-snowboarding.

You cannot build that URL from the sitemap data alone. SitemapResource has handle, title, image and updatedAt. No blog. On the demo store, the ARTICLE sitemap returned our-story as the bare handle.

The fix is a lookup. The articles connection does expose blog { handle }, so fetch article handles with their blog handles before calling getSitemap, and let getLink read from the map. The sitemap still supplies updatedAt for <lastmod>, which matters because Article has publishedAt but no updatedAt field of its own.

For metaobjects, getSitemap passes the metaobject’s type to getLink in place of metaObjects. Only types with the renderable capability appear at all. On the demo store that type is custom_pages, so the starter emits /custom_pages/snowboard-skis. The resource also carries onlineStoreUrlHandle (custom-pages on that store), but that is the Online Store theme’s URL handle, and the built-in query does not request it anyway.

A Hydrogen storefront serves metaobjects wherever you put a route, and the starter has none. Until you build one, take metaObjects out of types. When you do, map each renderable type to its route segment by hand. On a theme store, Shopify gives metaobject pages a template and a URL for you; in Hydrogen that mapping is your code.

How should hreflang alternates appear in a sitemap?

Look at what the starter’s locale branch produces. For one article, the <loc> is /articles/..., and the three alternates are /EN-US/articles/..., /EN-CA/articles/... and /FR-CA/articles/.... The starter has no ($locale) routes, so every alternate 404s, and the <loc> URL does not appear among its own alternates.

Google’s hreflang rules for sitemaps want a separate <url> element for every language version, and each element must list every alternate including itself. Values are case-insensitive, so EN-CA and en-ca are equivalent.

getSitemap writes one <url> per resource. Its getLink runs once without a locale (that result becomes <loc>) and once per entry in locales. So the way to get one <url> per version is one child sitemap per locale prefix, each using the route’s own locale for <loc> and the full list for the alternates. If you set up subfolder markets the way our Hydrogen i18n guide describes, the ($locale) optional segment is already there to reuse. Match the prefix casing your locale-aware Link produces: getLocaleFromRequest in the CLI’s subfolder strategy returns an upper-cased pathPrefix, and the sitemap should list the same URLs your canonical tags do.

A Hydrogen sitemap that only lists real URLs

Three files. The shared module holds the locale list, the types you have routes for, the path builder and the article lookup:

import type {Storefront} from '@shopify/hydrogen';

/** Every locale the storefront serves. The default locale has no prefix. */
export const SITEMAP_LOCALES = [
  {hreflang: 'en-US', prefix: ''},
  {hreflang: 'en-CA', prefix: '/en-ca'},
  {hreflang: 'fr-CA', prefix: '/fr-ca'},
] as const;

export type SitemapLocale = (typeof SITEMAP_LOCALES)[number];

/** Resource types this storefront has routes for. No metaObjects yet. */
export const SITEMAP_TYPES = [
  'products',
  'collections',
  'pages',
  'blogs',
  'articles',
] as const;

/** Renderable metaobject types and the route segment that serves them. */
const METAOBJECT_ROUTES: Record<string, string> = {
  // designer: 'designers',
};

export function localeFromParam(param?: string): SitemapLocale | undefined {
  if (!param) return SITEMAP_LOCALES[0];
  return SITEMAP_LOCALES.find((l) => l.prefix === `/${param.toLowerCase()}`);
}

export function resourcePath(
  type: string,
  handle: string,
  articleBlogs: Map<string, string>,
): string {
  switch (type) {
    case 'products':
    case 'collections':
    case 'pages':
    case 'blogs':
      return `/${type}/${handle}`;
    case 'articles': {
      const blog = articleBlogs.get(handle);
      if (!blog) {
        console.warn(`[sitemap] no blog found for article "${handle}"`);
        return '/blogs';
      }
      return `/blogs/${blog}/${handle}`;
    }
    default: {
      const segment = METAOBJECT_ROUTES[type];
      if (!segment) throw new Error(`[sitemap] no route for "${type}"`);
      return `/${segment}/${handle}`;
    }
  }
}

const ARTICLE_BLOGS_QUERY = `#graphql
  query SitemapArticleBlogs($after: String) {
    articles(first: 250, after: $after) {
      pageInfo {
        hasNextPage
        endCursor
      }
      nodes {
        handle
        blog {
          handle
        }
      }
    }
  }
` as const;

type ArticleBlogsPage = {
  articles: {
    pageInfo: {hasNextPage: boolean; endCursor: string | null};
    nodes: {handle: string; blog: {handle: string}}[];
  };
};

/** Article handle to blog handle, because the sitemap API omits the blog. */
export async function getArticleBlogs(storefront: Storefront) {
  const blogs = new Map<string, string>();
  let after: string | null = null;

  do {
    const page: ArticleBlogsPage = await storefront.query<ArticleBlogsPage>(
      ARTICLE_BLOGS_QUERY,
      {variables: {after}},
    );
    for (const {handle, blog} of page.articles.nodes) {
      const seen = blogs.get(handle);
      if (seen && seen !== blog.handle) {
        console.warn(`[sitemap] "${handle}" is in ${seen} and ${blog.handle}`);
        continue;
      }
      blogs.set(handle, blog.handle);
    }
    const {hasNextPage, endCursor} = page.articles.pageInfo;
    after = hasNextPage ? endCursor : null;
  } while (after);

  return blogs;
}

Rename the starter’s child route to app/routes/($locale).sitemap.$type.$page[.xml].tsx, so it answers both /sitemap/products/1.xml and /fr-ca/sitemap/products/1.xml. It returns a 404 for an unknown locale and for any type you have not opted into, which matters because the index is not the only way in. Anyone can request /sitemap/metaObjects/1.xml.

import type {Route} from './+types/($locale).sitemap.$type.$page[.xml]';
import {getSitemap} from '@shopify/hydrogen';
import {
  SITEMAP_LOCALES,
  SITEMAP_TYPES,
  getArticleBlogs,
  localeFromParam,
  resourcePath,
} from '~/lib/sitemap';

export async function loader({
  request,
  params,
  context: {storefront},
}: Route.LoaderArgs) {
  const current = localeFromParam(params.locale);
  const known = (SITEMAP_TYPES as readonly string[]).includes(params.type ?? '');
  if (!current || !known) throw new Response('Not found', {status: 404});

  const articleBlogs =
    params.type === 'articles'
      ? await getArticleBlogs(storefront)
      : new Map<string, string>();

  const response = await getSitemap({
    storefront,
    request,
    params,
    locales: SITEMAP_LOCALES.map((l) => l.hreflang),
    getLink: ({type, baseUrl, handle = '', locale}) => {
      const target = locale
        ? SITEMAP_LOCALES.find((l) => l.hreflang === locale)
        : current;
      const path = resourcePath(type, handle, articleBlogs);
      return `${baseUrl}${target?.prefix ?? ''}${path}`;
    },
  });

  response.headers.set('Cache-Control', `max-age=${60 * 60 * 24}`);
  return response;
}

The index lists the default locale through types and the prefixed locales through customChildSitemaps. Those are URL strings, so it needs page counts for each type, which is one more small query:

import type {Route} from './+types/[sitemap.xml]';
import {getSitemapIndex} from '@shopify/hydrogen';
import {SITEMAP_LOCALES, SITEMAP_TYPES} from '~/lib/sitemap';

const PAGE_COUNTS_QUERY = `#graphql
  query SitemapPageCounts {
    products: sitemap(type: PRODUCT) { pagesCount { count } }
    collections: sitemap(type: COLLECTION) { pagesCount { count } }
    pages: sitemap(type: PAGE) { pagesCount { count } }
    blogs: sitemap(type: BLOG) { pagesCount { count } }
    articles: sitemap(type: ARTICLE) { pagesCount { count } }
  }
` as const;

type PageCounts = Record<
  (typeof SITEMAP_TYPES)[number],
  {pagesCount: {count: number}}
>;

export async function loader({
  request,
  context: {storefront},
}: Route.LoaderArgs) {
  const counts = await storefront.query<PageCounts>(PAGE_COUNTS_QUERY);

  // The default locale is covered by `types`; list the prefixed ones here.
  const localeSitemaps = SITEMAP_LOCALES.filter((l) => l.prefix).flatMap(
    (locale) =>
      SITEMAP_TYPES.flatMap((type) =>
        Array.from(
          {length: counts[type].pagesCount.count},
          (_, i) => `${locale.prefix}/sitemap/${type}/${i + 1}.xml`,
        ),
      ),
  );

  const response = await getSitemapIndex({
    storefront,
    request,
    types: [...SITEMAP_TYPES],
    customChildSitemaps: localeSitemaps,
  });

  response.headers.set('Cache-Control', `max-age=${60 * 60 * 24}`);
  return response;
}

The two Route type imports come from React Router’s typegen, the same as in the starter. If you run Hydrogen’s GraphQL codegen, the explicit query<ArticleBlogsPage> and query<PageCounts> generics become optional.

Against the demo store, the article sitemap now gives <loc>https://shop.example.com/blogs/journal/10-tips-for-better-snowboarding</loc> with en-US, en-CA and fr-CA alternates, the first of them the <loc> itself. /fr-ca/sitemap/products/1.xml lists /fr-ca/products/... as <loc> with the same three alternates. The index lists five default child sitemaps and ten prefixed ones.

What can still go wrong with a custom sitemap?

The article lookup walks the whole articles connection, 250 at a time, whenever an article sitemap request misses Hydrogen’s sub-request cache. That is fine for a few thousand articles. For a publisher with tens of thousands, cache that query with storefront.CacheLong(), and accept that a brand-new article may sit on the /blogs fallback until the cache turns over.

The map is keyed on the article handle alone. If two blogs share a handle, the map keeps the first blog and logs the clash, so search your logs for [sitemap] after the first crawl.

getLink receives handle typed as optional, hence the handle = '' default. Every item the demo store returned had one.

Google allows 50,000 URLs or 50 MB per sitemap file. At 250 resources per Shopify page you will not come near that, but customChildSitemaps makes the index grow with locales times pages, so a store with ten markets and 40 product pages has 400 product entries. Still fine, though worth knowing before you add a twentieth locale.

Should you keep the starter sitemap?

On a single-locale store with no blog and no metaobject routes, yes. Delete locales from the child route and leave the rest, because products, collections and pages already resolve.

Change it the day you publish an article, add a renderable metaobject type, or launch a second market. Any of those puts 404s in the starter’s output, and none of them shows up as an error anywhere unless you open the XML and click a link. Open /sitemap.xml after each change and request three or four <loc> URLs from each child sitemap. Anything other than a 200 means getLink and your routes disagree.

Whoooop builds and fixes Hydrogen storefronts, including the SEO plumbing that is easy to ship broken: sitemaps, redirects and locale routing. If your headless store’s search traffic is not where it should be, our Shopify development team can audit what the crawler actually sees.

Need this built properly?

Get in touch