Replace client-side XSLT: transform XML on the server with PHP, Python or Node.js
Chrome stops applying XSL stylesheets on 17 November 2026. If your site sends XML with an XSL stylesheet, the most durable fix is to run the transformation on your server and send HTML. We ran the Python and Node.js examples below; the PHP one follows the PHP manual.
What replaces client-side XSLT in Chrome?
Server-side XSLT: run the XSL transformation on your server and send the browser plain HTML. In PHP, the XSLTProcessor class of the XSL extension does this; in Python, the XSLT class of lxml; in Node.js, a package such as saxon-js or xslt-processor. Each one takes your XML file and your XSL file and returns an HTML string that you send as text/html. It is the only route where the result no longer depends on what the browser supports.
XSLT stops working in Chrome 158, on 17 November 2026. Sites in the origin trial and browsers managed with an enterprise policy keep it until Chrome 176, on 17 August 2027. Our page on the XSLT removal timeline in Chrome lists the pages affected and every option for visitors.
Chrome’s announcement describes these routes for site owners:
- Run the XSLT processing on the server and send the rendered HTML.
- Add the xslt-polyfill script to the XML document, so that the stylesheet is applied in the browser again.
- Serve JSON instead of XML and render it with JavaScript.
- Run XSLT in the page with a JavaScript library. The announcement names Saxonica’s as the largest, with full support for XSLT 3.0.
The polyfill is the smallest change. Like a JavaScript library, it runs in every visitor’s browser. Running XSLT on the server takes more work, but the browser then receives an ordinary HTML page, with no script and no extension involved. Most of this article covers that route.
If you need more time, Chrome’s origin trial keeps XSLT working on your site until Chrome 176, in August 2027. You register your site for the trial and add its token to your pages’ responses; XML pages have no HTML head for a meta tag, so use the Origin-Trial HTTP response header that Chrome’s origin trials guide describes. If you cannot change the server at all, the polyfill remains the option.
Should I convert my sitemap or RSS feed to HTML?
No. Search engines and feed readers fetch them, and they need XML at that address.
Google’s sitemap documentation lists the formats it accepts: XML sitemaps, RSS, mRSS and Atom 1.0, and text files. An HTML page is not one of them. If you replace /sitemap.xml with HTML, you break it for search engines to fix a display problem that only affects people. Our article on what Google reads in a sitemap shows that the layout plays no part in how Google reads a WordPress sitemap.
Keep the XML where it is. If people need a readable version, publish the HTML at a separate address and add a link to it. For feeds, there is also a lighter option: styling feeds with CSS still works in Chrome.
Server-side transformation fits the XML pages that exist only to be read by people, such as a product catalog styled with XSL.
Why does my XSLT return an empty table for a sitemap?
Because sitemap elements belong to a namespace, and the stylesheet has to match them with a prefix. The examples in this article use two files that build a readable copy of a sitemap, served at its own address such as /sitemap.html, while /sitemap.xml stays as it is. The XML is a short sitemap:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://example.com/</loc><lastmod>2026-10-14</lastmod></url>
<url><loc>https://example.com/about/</loc></url>
</urlset>
The stylesheet, sitemap.xsl, turns it into an HTML table:
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" exclude-result-prefixes="s"
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"/>
<xsl:template match="/">
<html>
<body>
<h1>Sitemap</h1>
<table>
<xsl:for-each select="s:urlset/s:url">
<tr>
<td><xsl:value-of select="s:loc"/></td>
<td><xsl:value-of select="s:lastmod"/></td>
</tr>
</xsl:for-each>
</table>
</body>
</html>
</xsl:template>
</xsl:stylesheet>
XSLT 1.0 takes its path expressions from the W3C XML Path Language 1.0, where a name without a prefix only matches elements in no namespace: the document’s default namespace is ignored. That is why the stylesheet declares the prefix s and writes s:urlset/s:url. With urlset/url, the table would come out empty.
The exclude-result-prefixes="s" attribute keeps that namespace declaration out of the HTML. Without it, in our run with lxml, the output started with <html xmlns:s="...">.
With libxslt, which the PHP and Python examples both use, the output has no DOCTYPE: our lxml run started with <html><body>. MDN explains that a page needs a doctype such as <!doctype html> to be rendered in standards mode rather than quirks mode. Add doctype-system="about:legacy-compat" to xsl:output, the legacy string that the HTML standard defines for generators like this one, and lxml then wrote <!DOCTYPE html>. The indent="yes" attribute, by contrast, only allows the processor to add whitespace, the way an XML formatter would: libxslt added line breaks between most elements, but no indentation.
How do I run XSLT in PHP, Python or Node.js?
PHP’s XSL extension and Python’s lxml both run on libxslt, which Chrome’s announcement names as the XSLT library of Chromium browsers. Node.js needs a package.
Chrome’s announcement explains the removal by security: libraries such as libxslt are complex, aging C/C++ code, prone to memory-safety bugs. On your own server, you choose what goes in: run your own stylesheets on your own XML, never a stylesheet sent by a user, and keep libxslt up to date with your system updates.
PHP
Use the XSLTProcessor class from PHP’s XSL extension. The PHP manual says that PHP includes the extension, which is enabled with the --with-xsl option when PHP is built, so first check that php -m lists xsl. On the PHP 8.3 we had at hand, from the official WordPress Docker image, it did not, and XSLTProcessor was missing.
<?php
$xml = new DOMDocument();
$xml->load('sitemap.xml');
$xsl = new DOMDocument();
$xsl->load('sitemap.xsl');
$proc = new XSLTProcessor();
$proc->importStylesheet($xsl);
$html = $proc->transformToXml($xml);
if ($html === false) {
http_response_code(500);
exit;
}
header('Content-Type: text/html; charset=utf-8');
echo $html;
transformToXml returns the result as a string, or false on error, so the code checks it before sending any header. Since the XSL extension was missing, this example follows the manual and was not run.
Python
Install lxml with pip install lxml. According to its documentation, lxml supports XSLT 1.0 through libxml2 and libxslt.
from lxml import etree
doc = etree.parse("sitemap.xml")
transform = etree.XSLT(etree.parse("sitemap.xsl"))
result = transform(doc)
html = str(result)
The result is serialized as the stylesheet’s xsl:output element specifies. To get a Python text string from str(result), that element should declare UTF-8, as ours does, according to the lxml documentation. We ran this with Python 3.14.3 and lxml 6.0.2: the output was the HTML table, with two rows and an empty date cell for the second page.
Node.js
Node.js has no XSLT processor among its built-in modules, so you need a package. We tested two.
SaxonJS. The saxon-js package, from Saxonica, is an XSLT 3.0 processor for Node.js. XSLT 3.0 defines a compatibility mode for stylesheets marked version="1.0", and our stylesheet ran unchanged. SaxonJS.transform() takes a compiled stylesheet, a SEF file, so compile it once with the xslt3 command:
npm install saxon-js xslt3
npx xslt3 -xsl:sitemap.xsl -export:sitemap.sef.json -nogo
Then run it from an ES module, for example a file named transform.mjs:
import SaxonJS from 'saxon-js';
const output = await SaxonJS.transform({
stylesheetFileName: 'sitemap.sef.json',
sourceFileName: 'sitemap.xml',
destination: 'serialized',
}, 'async');
const html = output.principalResult;
The saxon-js README describes SaxonJS as free of charge, with a license that is not open source.
xslt-processor. This package is written in JavaScript, with no native dependencies. Its README shows the call: parse both files with its XmlParser class, then pass them to the xsltProcess method of an Xslt object, which returns a promise of the result. The README warns that some features required by the standards were left out, and that the package identifies XSLT elements by their xsl: prefix and ignores namespace declarations. Our sample worked with it; test your own stylesheets before you rely on it.
When is the xslt-polyfill script enough?
When you can edit the XML files but not the code that serves them. The polyfill applies the stylesheet in the browser, with JavaScript: the XSL stays as it is, and the XML only gains one script line, which our XSLT removal timeline page shows. The xslt-polyfill README adds three practical points:
- the script is published on npm as
xslt-polyfill, and you serve the file from your own site; - it only applies a stylesheet that the document already links to with an
xml-stylesheetline; - while native XSLT is on, the browser transforms the document before any script runs, so test the polyfill with XSLT turned off at
chrome://flags/#xslt.
The content type matters too. In our test with XSLT turned off, a script element placed in an RSS feed ran when the feed was sent as application/xml, but not when it was sent as application/rss+xml: Chrome then showed the feed as plain text. Chrome’s announcement says that adding the line to a feed should not stop feed readers from parsing the XML. Google’s sitemap documentation says nothing about it, so after adding it to a sitemap, check the Sitemaps report in Search Console.
The trade-off is that every visitor now runs a script to see your page. With the server-side route, they receive finished HTML.
Which Content-Type should the generated HTML use?
text/html. MDN lists text/html as the media type for HTML documents, so send Content-Type: text/html; charset=utf-8, as in the PHP example. The page then works in every browser, with or without XSLT.
Visitors who land on an untransformed page can already install the official XSLT Polyfill extension. XMLNest, an XML viewer for Chrome, is available on the Chrome Web Store. Why I built XMLNest tells the story behind it.
Sources
- Chrome for Developers: Removing XSLT for a more secure browser (checked on 14 October 2026)
- Chrome for Developers: Get started with origin trials (checked on 14 October 2026)
- XSLT Polyfill, README of mfreed7/xslt_polyfill on GitHub (checked on 14 October 2026)
- W3C: XSL Transformations (XSLT) Version 1.0 (checked on 14 October 2026)
- W3C: XML Path Language Version 1.0, Node Tests (checked on 14 October 2026)
- W3C: XSL Transformations (XSLT) Version 3.0, XSLT 1.0 Compatibility Mode (checked on 14 October 2026)
- HTML Standard: The DOCTYPE (checked on 14 October 2026)
- MDN: Understanding quirks and standards modes (checked on 14 October 2026)
- PHP manual: XSLTProcessor::transformToXml (checked on 14 October 2026)
- PHP manual: XSL extension, Installation (checked on 14 October 2026)
- PHP manual: Command line options (checked on 14 October 2026)
- lxml: XSLT (checked on 14 October 2026)
- Node.js documentation: API index (checked on 14 October 2026)
- npm: saxon-js (checked on 14 October 2026)
- npm: xslt-processor (checked on 14 October 2026)
- Google Search Central: Build and Submit a Sitemap (checked on 14 October 2026)
- MDN: Common media types (checked on 14 October 2026)
- Test by XMLNest : Run by XMLNest on 14 October 2026 on the sample files of this article: Python 3.14.3 with lxml 6.0.2 (libxslt 1.1.39) produced the HTML table with two rows, starting with <html><body> and no DOCTYPE, then with <!DOCTYPE html> once doctype-system="about:legacy-compat" was added; Node.js 24.20.0 with saxon-js 2.7.0 and xslt3 2.7.0, then with xslt-processor 5.1.2, produced the same table. In Chromium 156.0.8078.4 with the XSLT feature turned off, a script element in the XHTML namespace placed in an RSS feed ran when the feed was sent as application/xml, and not when it was sent as application/rss+xml. PHP 8.3.35 in the official wordpress:7.1.3-php8.3-apache Docker image had no XSL extension, so the PHP example follows the PHP manual and was not run (checked on 14 October 2026)