Introduction

A common architecture in Digital Humanities is to transform TEI (Text Encoding Initiative) XML data into HTML using XSLT and publish it on the web.

Traditionally, client-side XSLT transformation in the browser (via <?xml-stylesheet?> or JavaScript’s XSLTProcessor) has been the standard approach, but it comes with several challenges:

  • The browser executes XSLT transformation on every page load, resulting in slow rendering
  • Poor SEO and web crawler support
  • Inconsistent XSLT implementations across browsers

This article shows how to run XML-to-HTML transformation at build time on Vercel and serve pre-generated static HTML.

Project Structure

project/
├── docs/              # Vercel output directory
│   ├── index.html     # Top page
│   └── data/
│       ├── *.xsl          # XSLT stylesheet
│       ├── *.sef.json     # Compiled stylesheet
│       ├── *.xml          # TEI/XML source
│       └── *.html         # Generated HTML (built at deploy time)
├── build.js           # Build script
├── package.json
└── vercel.json

Comparing Node.js XSLT Libraries

Since native tools like xsltproc are not available in Vercel’s build environment, we need a Node.js XSLT library. We evaluated three options.

xsltproc (Reference: Local Environment)

A C-based XSLT processor that comes pre-installed on macOS.

xsltproc docs/data/main.xsl docs/data/劉興我本巻一.xml > docs/data/劉興我本巻一.html

Completes almost instantly, but cannot be used on Vercel (apt-get is unavailable).

xslt-processor (Pure JavaScript)

npm install xslt-processor

A library updated to ES2015+ based on Google’s AJAXSLT (2005). Originally a polyfill for browsers lacking XSLT support. Even for a modest 1,400-line XML file, transformation took several minutes, making it impractical.

The slowness stems from:

  • XPath expressions are parsed and evaluated at runtime every time (no caching or pre-compilation)
  • No optimization strategy, causing cumulative overhead in XPath evaluation
  • Pure JavaScript DOM implementation overhead for tree traversal

saxon-js (by Saxonica)

npm install saxon-js xslt3

A high-performance XSLT processor developed by Saxonica, the company led by Michael Kay, editor of the XSLT 3.0 specification. Its key feature is pre-compilation:

  1. The XSLT stylesheet is compiled into SEF (Stylesheet Export File), a JSON format
  2. At runtime, it simply loads and executes the pre-compiled SEF
# Compile XSLT to SEF (one-time)
npx xslt3 -xsl:docs/data/main.xsl -export:docs/data/main.sef.json -nogo

The same file was transformed in 0.5 seconds.

Comparison

ToolImplementationProcessing TimeVercel Compatible
xsltprocC/Native<0.1sNo
saxon-jsPre-compiled + JS runtime0.5sYes
xslt-processorPure JS runtimeMinutes+Yes (impractical)

Implementation

build.js

const fs = require('fs');
const path = require('path');
const SaxonJS = require('saxon-js');

const dataDir = path.join(__dirname, 'docs', 'data');
const sefPath = path.join(dataDir, 'main.sef.json');
const sefText = fs.readFileSync(sefPath, 'utf-8');
const sef = JSON.parse(sefText);

const xmlFiles = fs.readdirSync(dataDir).filter(f => f.endsWith('.xml'));

for (const file of xmlFiles) {
  const xmlPath = path.join(dataDir, file);
  const htmlPath = xmlPath.replace(/\.xml$/, '.html');
  const xmlText = fs.readFileSync(xmlPath, 'utf-8');
  console.log(`Processing: ${file}...`);
  const result = SaxonJS.transform({
    stylesheetInternal: sef,
    sourceText: xmlText,
    destination: 'serialized'
  });
  fs.writeFileSync(htmlPath, result.principalResult, 'utf-8');
  console.log(`Built: ${file} -> ${file.replace(/\.xml$/, '.html')}`);
}

package.json

{
  "scripts": {
    "build": "node build.js"
  },
  "dependencies": {
    "saxon-js": "^2.7.0"
  },
  "devDependencies": {
    "xslt3": "^2.7.0"
  }
}

vercel.json

{
  "buildCommand": "node build.js",
  "outputDirectory": "docs"
}

Vercel Project Settings

Confirm the following in the Vercel dashboard or via vercel project inspect:

  • Root Directory: Project root (not docs, but the repository root)
  • Build Command: node build.js (specified in vercel.json)
  • Output Directory: docs (specified in vercel.json)

If the Root Directory is set to a subdirectory (e.g., docs), the build will fail because build.js and package.json won’t be found.

Workflow for XSLT Stylesheet Changes

When the XSLT stylesheet (main.xsl) is modified, the SEF needs to be recompiled:

# Recompile SEF
npx xslt3 -xsl:docs/data/main.xsl -export:docs/data/main.sef.json -nogo

# Local verification
node build.js

# Commit & push (Vercel auto-deploys)
git add -A && git commit -m "update" && git push

For XML data changes only, SEF recompilation is not needed — just push, and Vercel will generate the HTML at build time.

Summary

  • Moving XSLT transformation from client-side to build-time improves rendering speed and SEO
  • Native tools (xsltproc) are unavailable in Vercel’s build environment, but saxon-js provides fast transformation
  • xslt-processor is too slow for large XML files due to its pure JavaScript implementation
  • saxon-js’s pre-compilation (SEF) approach keeps build time under 1 second