The short version
Astro already generates clean, fast-loading HTML, but built files may still contain comments, redundant whitespace, unused CMS attributes, and headings without anchor IDs. A small Node script can process every built HTML file: minify it with html-minifier-terser, preserve existing IDs, and add unique IDs where they are missing.
Astro already generates clean, fast-loading HTML. There can still be room to tidy the built files: comments, redundant whitespace, unused CMS attributes, and headings without anchor IDs. A small Node script that runs after astro build can process every HTML file at once. The example below minifies the files with html-minifier-terser and adds unique IDs only to headings that do not already have one. Here is how to set it up, what it can save, and what to watch out for.
Getting started
Before you start, make sure you have the following installed:
Installing the dependencies
The script depends on three packages: globby, html-minifier-terser, and jsdom. Install them as development dependencies:
npm install --save-dev globby html-minifier-terser jsdomThe process-html.mjs script
Create a file named process-html.mjs in the root of your Astro project. The code below uses Astro’s default static output folder, dist. If you configured another output directory, adjust the path variable.
import fs from 'node:fs/promises'
import { globby } from 'globby'
import { minify } from 'html-minifier-terser'
import { JSDOM } from 'jsdom'
// Find all HTML files in the output folder
const path = './dist'
const files = await globby(`${path}/**/*.html`)
await Promise.all(
files.map(async (file) => {
console.log('Processing file:', file)
let html = await fs.readFile(file, 'utf-8')
// Preserve existing IDs and add unique IDs to other h2, h3 and h4 elements
const dom = new JSDOM(html)
const headings = dom.window.document.querySelectorAll('h2, h3, h4')
const usedIds = new Set(
[...dom.window.document.querySelectorAll('[id]')].map((element) => element.id)
)
for (const heading of headings) {
if (heading.id) continue
const baseId = heading.textContent
.normalize('NFKD')
.replace(/\p{Mark}+/gu, '')
.trim()
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}]+/gu, '-')
.replace(/^-|-$/g, '') || 'section'
let id = baseId
let suffix = 2
while (usedIds.has(id)) id = `${baseId}-${suffix++}`
heading.setAttribute('id', id)
usedIds.add(id)
}
html = dom.serialize()
// Minify the resulting HTML
html = await minify(html, {
removeComments: true,
preserveLineBreaks: true,
collapseWhitespace: true,
})
await fs.writeFile(file, html)
})
)Updating package.json
Update package.json to run the script after the build. Add && node process-html.mjs after astro build, as shown below.
{
"name": "astro-process-html",
"type": "module",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build && node process-html.mjs",
"preview": "astro preview",
"astro": "astro"
}
}What does it change in practice?
Minification produces the smallest gain. Astro already minifies HTML by default (compressHTML is enabled), so a second minifier mainly removes comments and remaining whitespace. In an earlier build of this site, the homepage went from 231,422 to 230,770 bytes (0.28 percent smaller), while this article went from 189,416 to 189,027 bytes (0.21 percent). Do not expect dramatic savings.
The transformations were the useful part in that build. Heading IDs kept the table of contents and deep links working. That version also removed empty paragraphs left by the CMS and stripped redundant link attributes such as linktype and uuid from the HTML. The difference was small on each page, but across hundreds of pages it produced cleaner files and consistent anchors on every build.
Pitfalls you should know
- Put the script in your build pipeline rather than running it manually. Every build overwrites the output folder, so the next deploy would discard a manual run. That is why
package.jsonincludes it above. - Keep
preserveLineBreaks: trueenabled. Without it, your HTML collapses onto one line, which makes built files much harder to diff or debug. - jsdom parses and serializes every page again. That is usually safe for ordinary HTML, but test hydrated Svelte or other framework islands whenever you extend the script.
collapseWhitespacecan remove a visible space around inline elements in some edge cases. Check the design after the first run, especially around buttons and icons next to text.
Alternative: the astro:build:done integration
If you prefer an Astro integration to a standalone npm script, wrap the same logic in one. It runs on the astro:build:done hook and receives the output directory, so you do not have to hardcode paths. The integration is more reusable but slightly more involved: a standalone script is fine for one site, while an integration pays off across several projects.
// astro.config.mjs
function processHtml() {
return {
name: 'process-html',
hooks: {
'astro:build:done': async ({ dir }) => {
// Run the same globby and minification logic as process-html.mjs,
// using every .html file in the output directory.
},
},
}
}
export default defineConfig({
integrations: [processHtml()],
})Both approaches do the same thing: after the build, they process every HTML file. Choose the one that best fits how you manage projects.
Conclusion
With a small post-build script, you can add reliable heading anchors, clean up generated attributes, and make HTML files slightly smaller. The measured reduction in the earlier build was only 0.2 to 0.3 percent because Astro already minifies HTML well. Add the script for a specific transformation, not for dramatic compression.
To keep improving speed, read more about website performance. If you want someone else to maintain this kind of pipeline, see website maintenance or get in touch with a question. The html-minifier-terser reference covers the remaining configuration options.

