All articles
08 · Search & GEO · 2026.10 · 5 min

From an XSLT-styled sitemap.xml to a real HTML sitemap page

I first dressed up sitemap.xml with an XSLT stylesheet so that people could read it too. When browsers set out to remove XSLT support, I dropped it: the XML stayed for machines only, and for people I wrote a bilingual, prerendered /sitemap page.

01Showing XML to people

The site's sitemap.xml lists every page in two languages; each entry also carries its counterpart in the other language and the page's share card. That is exactly what search engines need. But a person who opens the file in a browser sees a tree of nested tags. I wanted the sitemap to be useful for people too: which pages exist, in which language, at which address.

The first solution was an old and well-known one. I added an xml-stylesheet processing instruction to the top of the XML file and pointed the browser at an XSLT file at /sitemap.xsl. That file was also generated in scripts/seo-files.mjs and served as text/xsl. The browser ran the XML through this transform and showed an HTML table in the site's colours: share cards, language tags and links to the other language. Crawlers kept reading the raw XML.

scripts/seo-files.mjs (old version)
<?xml-stylesheet type="text/xsl" href="/sitemap.xsl"?>

<xsl:stylesheet version="1.0"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
  xmlns:s="http://www.sitemaps.org/schemas/sitemap/0.9">
  <xsl:output method="html" encoding="UTF-8" indent="yes"
    doctype-system="about:legacy-compat"/>
  <xsl:template name="rel">
    <xsl:param name="u"/>
    <xsl:variable name="rest"
      select="substring-after(substring-after($u, '://'), '/')"/>
    <xsl:value-of select="concat('/', $rest)"/>
  </xsl:template>

02Why I gave it up

When I opened the page in Chrome, the console showed a warning that XSLT is being deprecated. Browsers are on the way to removing XSLT support. Tying a feature to a technology that is openly on its way out is a dead end; the fact that it works today does not mean it will not quietly stop working one day.

Even without the warning, this path had other costs. XSLT 1.0 is a separate and fairly limited language: even turning an absolute address into a relative one meant writing a template with nested substring-after calls, as above. The view lived entirely outside the Vue app; the components, the menu and the design tokens were not there. I had rewritten the colours in a separate style block inside the XSL file. Whenever a colour changed on the site, I would have had to remember that copy too.

03XML for machines, HTML for people

In the end I split the job in two. The XSL file and the processing instruction went away; sitemap.xml became plain XML again and is there for machines only. A comment at the top of the file shows anyone who opens it the addresses of the Turkish and English sitemap pages, and the robots.txt, llms.txt and llms-full.txt files.

The split did no harm to sitemap.xml. Everything that matters to search engines is in place: for every address, xhtml:link alternates for tr, en and x-default, the share card as image:image, lastmod, changefreq and priority. The home page is listed at 1.0, top-level pages at 0.8, project and article pages at 0.6, and the sitemap page itself at 0.3.

For people there is src/views/SitemapView.vue. It is a Vue route like the others: it lives at /sitemap and /en/sitemap, is prerendered in both languages at build time, has its own share card, and since it is in publicPaths, it is listed in sitemap.xml as well. The page is reached from a link in the footer.

04The /sitemap page

The page shows three groups: pages, projects and articles. Pages are listed from the home page and the menu links, projects with category, year and status, articles with category and date. Each row has a small image from the page's share card, a title link, the page's address in mono type, a short subline and a link to its counterpart in the other language. That last link carries an hreflang attribute. The card images are deliberately loaded from relative addresses, so the same page works the same on localhost, in previews and on the live site. The counter at the top shows twice as many addresses as the length of publicPaths.

At the bottom there is a "For machines" section: sitemap.xml, robots.txt, llms.txt and llms-full.txt, each with a short description. The page's JSON-LD contains a CollectionPage node with an ItemList that counts every public page in order, along with a BreadcrumbList. So machines can recognise this page as a directory too.

Rather than trying to make people read XML, it is more honest to give people a page of their own.

05The same files on the dev server

Since the sitemap page links to the machine files and shows the share cards, those files had to exist during development too. Normally they are all generated at the end of the build. A small plugin in vite.config.ts runs only on the dev server and intercepts requests for /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt and /og/<key>.png. It reads the content through entry-server.ts with Vite's SSR loader and calls the same textFiles and renderOg functions the build uses.

vite.config.ts
const isText = ['/robots.txt', '/sitemap.xml', '/llms.txt', '/llms-full.txt'].includes(url)
const og = /^\/og\/([a-z0-9-]+)\.png$/.exec(url)
if (!isText && !og) return next()
const mod = await load(server) // ssrLoadModule('/src/app/entry-server.ts')
const seo = await import('./scripts/seo-files.mjs')

This has a nice side effect: the file I see on the dev server and the file that goes live come from the same code. When I change an article's title and reload the page, sitemap.xml, llms.txt and the card image have changed too.

Related articles
  1. 09I stopped chasing the pointer: a native SVG cursor with CSS cursorDesign & interaction2026.106 min
  2. 10Pause what you cannot see: sleeping loops offscreen and behind overlaysInterface2026.106 min