This is a standalone example for static Astro projects. The site where I measured it, straffesites.com, does not use the script in production. In October 2026, I did run it on a copy of that site's build to measure what a post-build step does to your Astro HTML. The script adds unique IDs to headings that do not have one yet and minifies the files with html-minifier-terser. Here is how to set it up, what the measurement showed, and what to watch out for.
What do you need to get 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 jsdom
The 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: false,
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?
I ran the script above exactly as shown on a copy of the straffesites.com build: 236 HTML pages, built with Astro 7.2.8 and the default compressHTML setting. The package versions were globby 16.2.4, html-minifier-terser 7.2.0, and jsdom 30.1.2. The result is the opposite of what you might expect: the Astro HTML did not get smaller, it got larger.
| Measurement | Before the script | After the script | Difference |
|---|---|---|---|
| All 236 HTML files | 31,807,539 bytes | 32,067,896 bytes | +0.82% |
| Same files with gzip | 6,573,834 bytes | 6,637,927 bytes | +0.97% |
| Same files with Brotli | 5,506,134 bytes | 5,562,603 bytes | +1.03% |
| Heading IDs added | – | 6,552 on 236 pages | on every page |
Two steps explain it. The minify step alone, with the same options, removed 408 bytes across the whole build, because Astro had already cleaned up the whitespace. Parsing and serializing with jsdom alone made the files 38,059 bytes larger, partly because jsdom writes every empty attribute in full (data-site-header becomes data-site-header=""). The rest comes from the new IDs. Processing took 25.7 seconds.
The Astro documentation explains why compressHTML already does so much:
"Since v7.0, Astro applies by default the JSX whitespace rules used by frameworks like React."
So the value of the script is in the transformation, not in bytes. Every h2, h3, and h4 gets a stable, unique anchor ID, so you can link to any section. Existing IDs stay in place. The example does not remove empty paragraphs or CMS attributes; that needs extra, tested logic. Measure your own build to decide whether the anchors are worth the extra bytes.
Which pitfalls should you know about?
Keep HTML comments when using Svelte 5 or other comment-based hydration. That is why this example sets removeComments: false. See the Svelte 5 migration guide.
- 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. - Do not expect smaller files. jsdom writes empty attributes in full and every new ID costs bytes, so on an Astro 7 build your HTML is more likely to grow. Use the script for the anchors, not for the file size.
Is there an alternative to a standalone script?
If you only want smaller files and no heading anchors, you do not need to write anything yourself. An existing integration such as @playform/compress runs html-minifier-terser together with minifiers for CSS, JavaScript, SVG, and images in your Astro build. Measure the difference there too, because Astro has already compressed most of the HTML.
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.
This is only the integration skeleton, not a second complete script. Add the file processing above and convert the dir URL with fileURLToPath from node:url before using it as a globby path. Do not enable this alternative together with the npm post-build script.
// astro.config.mjs
import { defineConfig } from 'astro/config'
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 few lines after the build, all your Astro HTML gets stable heading anchors on every deploy. You will not get smaller files: Astro already compresses the HTML, and on the measured site the build grew 0.82%. Use the script for a specific transformation and measure the effect on your own build.
The html-minifier-terser reference covers the remaining configuration options.
Originally published on straffesites.com.
Top comments (0)