npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vite-plugin-html-pages

v2.4.0

Published

Minimal static site generation (SSG) for Vite using JavaScript functions that return HTML

Readme

vite-plugin-html-pages

npm version npm downloads license vite

Static site generation for Vite — no framework, no components, no magic.

Write JavaScript (or TypeScript, or JSX) functions that return HTML. Get a complete static site with file-based routing, dynamic pages, data loading, an asset pipeline, sitemap, RSS, and a live dev server.

⭐ If this project helps you, please consider starring it.


TL;DR

Write a function that returns HTML:

// src/index.ht.js

export default () => `
  <html lang="en">
    <head>
      <title>My website</title>
      <link rel="stylesheet" href="/styles.css">
    </head>
    <body>
      <h1>Hello world</h1>
    </body>
  </html>
`

Run:

vite build

Get:

dist/
  index.html      ← rendered, with <!DOCTYPE html> added for you
  styles.css      ← bundled + minified, because your page referenced it
  404.html        ← generated automatically

That's the whole mental model. Everything else is convenience on top.


Features

  • File-based routingsrc/about.ht.js/about
  • Dynamic routes[slug], [year]/[slug], catch-all [...path], optional catch-all [...path]?
  • Route groups(admin)/users.ht.js/users
  • Bring your own HTML — template literals, javascript-to-html, or JSX/TSX
  • Data loadingdata() runs at build time, with built-in fetch caching
  • Typed pages — per-route param types inferred from the filename
  • Smart asset pipeline — JS/TS/CSS referenced by your HTML is bundled and minified; server-only code never leaks into dist
  • Asset validation — broken <script src> / stylesheet links fail the build
  • Real dev server — pages render on request (dynamic routes included, no generateStaticParams needed in dev) with full reload and readable error frames
  • Parallel static generation — renders large sites concurrently
  • 404.html, sitemap.xml, RSS — generated for you

Why this exists

Modern static site tools are powerful, but they bring frameworks, component systems, hydration strategies, and opinionated conventions.

Sometimes you just want to:

  • write HTML
  • organize pages in folders
  • run vite build

vite-plugin-html-pages exists for exactly that. Pages are plain functions that return a string of HTML. No runtime ships to the browser unless you add a script.


Installation

npm install -D vite-plugin-html-pages

Requires Node 18+ and Vite 8+.

// vite.config.js
import { defineConfig } from "vite"
import htmlPages from "vite-plugin-html-pages"

export default defineConfig({
  plugins: [htmlPages()]
})
vite        # dev server with live rendering
vite build  # static site in dist/

Add the generated helper types to your .gitignore:

.vite-plugin-html-pages/

Project structure

src/
  index.ht.js            → /
  about.ht.js            → /about
  styles.css             → bundled if referenced
  main.js                → bundled if referenced
  lib/
    api.js               → build-time only (never emitted unless referenced)

  blog/
    index.ht.js          → /blog
    [slug].ht.js         → /blog/:slug

  docs/
    [...path]?.ht.js     → /docs, /docs/a, /docs/a/b, ...

  (admin)/
    users.ht.js          → /users

  404.ht.js              → dist/404.html

Any file ending in a page extension is a page. Everything else in src/ is treated as an asset (see Assets).

Default page extensions: .ht.js, .html.js, .ht.ts, .html.ts, .ht.jsx, .html.jsx, .ht.tsx, .html.tsx.


Writing pages

A page module's default export can be any of the following.

1. A function returning an HTML string

export default ({ params, data, dev }) => `
  <html>
    <body><h1>Hello</h1></body>
  </html>
`

2. A plain string

export default `<html><body><h1>Static as it gets</h1></body></html>`

3. A structured module

Keeps render, data, and generateStaticParams together in one object:

export default {
  generateStaticParams: () => [{ slug: "hello" }],
  data: ({ params }) => ({ title: params.slug }),
  render: ({ data }) => `<html><body><h1>${data.title}</h1></body></html>`,
}

4. JSX / TSX

Name the file *.ht.jsx or *.ht.tsx and return JSX — it is rendered to static HTML at build time with react-dom/server:

// src/index.ht.tsx
export default function Home() {
  return (
    <html lang="en">
      <head><title>My site</title></head>
      <body><h1>Hello from TSX</h1></body>
    </html>
  )
}

JSX pages require react and react-dom in your project (they are optional peer dependencies — string-based pages don't need them). Since output is static, event-handler props like onClick won't do anything in the browser; the dev server warns you if it finds any.

5. javascript-to-html

Prefer composable functions over template strings? The companion library javascript-to-html works great:

import { html, head, title, body, h1 } from 'javascript-to-html'

export default () =>
  html({ lang: 'en' },
    head(title('My website')),
    body(h1('Hello world'))
  )

If a page's output starts with <html>, <!DOCTYPE html> is prepended automatically.

Render context

Every page function receives one argument:

| Property | Type | Description | |----------|------|-------------| | params | Record<string, string \| string[]> | Route params for this page | | data | unknown | Whatever your data() returned | | page | object | Route metadata (routePath, relativePath, ...) | | dev | boolean | true in the dev server, false at build |


Routing

Routes come straight from the filesystem:

| Feature | File | URL | |---------|------|-----| | Static routes | index.ht.js | / | | Nested routes | blog/index.ht.js | /blog | | Dynamic routes | blog/[slug].ht.js | /blog/my-post | | Multiple params | blog/[year]/[slug].ht.js | /blog/2026/my-post | | Catch-all | docs/[...path].ht.js | /docs/api/auth/login | | Optional catch-all | docs/[...path]?.ht.js | /docs and /docs/anything/below | | Index routes | products/[id]/index.ht.js | /products/iphone-18 | | Route groups | (admin)/users.ht.js | /users |

More specific routes always win: static segments beat dynamic ones, dynamic beat catch-alls. Two files generating the same URL is a build error, not a silent overwrite.

Static params

Dynamic routes declare their pages by exporting generateStaticParams:

// src/blog/[slug].ht.js
export function generateStaticParams() {
  return [
    { slug: 'hello-world' },
    { slug: 'my-first-post' },
  ]
}

export default ({ params }) => `
  <html><body><h1>${params.slug}</h1></body></html>
`

Values can be strings, numbers, or booleans — they are stringified and URL-encoded for you. Catch-all params accept arrays ({ path: ['a', 'b'] }) or slash-separated strings ({ path: 'a/b' }).

A dynamic page that generates zero routes prints a warning so it can't silently vanish from your site.


Data loading

Export a data() function and its result appears as ctx.data in your render function. It runs at build time (and per-request in dev):

export async function data({ params, dev }) {
  const res = await fetch(`https://api.example.com/posts/${params.slug}`)
  return await res.json()
}

export default ({ data }) => `
  <html><body>
    <h1>${data.title}</h1>
    ${data.body}
  </body></html>
`

fetchWithCache

Building 500 pages against the same API? Cache the responses:

import { fetchWithCache } from 'vite-plugin-html-pages'

export async function data({ params }) {
  const res = await fetchWithCache(
    `https://api.example.com/posts/${params.slug}`,
    { /* standard fetch options */ },
    { maxAge: 3600 }
  )
  return { post: await res.json() }
}

| Option | Description | |--------|-------------| | maxAge | Cache TTL in seconds (default: 3600) | | cacheKey | Custom cache key (default: hash of URL + method + headers + body) | | forceRefresh | Bypass the cache and fetch fresh | | cache | 'auto' | 'memory' | 'fs' | 'none' |

Cache modes:

  • auto (default) — memory in dev, filesystem in production builds
  • memory — in-process, cleared when the process exits
  • fs — persisted in node_modules/.cache/vite-plugin-html-pages/fetch/
  • none — always fetch

Only GET requests are cached by default (pass a cacheKey to cache other methods), and error responses are never cached — a flaky API during one build won't poison the next one.


TypeScript & typed params

Pages can be written in TypeScript (.ht.ts / .ht.tsx) with zero configuration.

Helper functions give your page modules full type inference:

// src/blog/[slug].ht.ts
import { definePageModule } from 'vite-plugin-html-pages/page'

export default definePageModule({
  generateStaticParams: () => [{ slug: 'hello' }],
  data: ({ params }) => ({ title: params.slug }),
  render: ({ data }) => `<html><body><h1>${data.title}</h1></body></html>`,
})

Individual helpers (definePage, defineData, defineStaticParams) are also exported. At build time this import is transparently swapped for a per-route generated module whose PageParams are inferred from the filename: [slug]{ slug: string }, [...path]{ path: string[] }, [...path]?{ path?: string[] }.

Matching type declarations are generated into .vite-plugin-html-pages/types/ whenever the dev server or a build runs — add that folder to .gitignore.


Assets & styling

Reference assets from your HTML with root-relative URLs and the plugin handles the rest:

export default () => `
  <html>
    <head>
      <link rel="stylesheet" href="/styles.css">
      <script type="module" src="/main.js"></script>
    </head>
    <body>...</body>
  </html>
`

At build time:

  • Referenced JS / TS / CSS is bundled with esbuild — imports are inlined, output is minified, and .ts files compile to .js.
  • Unreferenced code files are not emitted. A helper like src/lib/api.ts that you only import from data() stays out of dist/ — server-only code (and its secrets) never ships by accident.
  • Everything else is copied (images, fonts, videos, ...), so CSS url() references keep working.
  • public/ behaves like normal Vite — copied verbatim.

In dev, the same URLs are served through Vite's transform pipeline, so TypeScript and CSS work identically without a build.

Missing-asset validation

Every generated page is checked: a <script src="/x.js"> or stylesheet href pointing at a file that exists in neither src/ nor public/ fails the build with the exact paths that were checked. Prefer a warning instead?

htmlPages({ missingAssets: 'warn' })

Literal import('/x.js') strings in the HTML are checked too, but only ever warn — a string in an attribute is a weaker signal than a src. Samples inside <pre> and <code> are ignored, so documenting a handler never pulls it into the check.

Assets another plugin serves

Some URLs have no file here to find: a sibling plugin answers them from its own package, or a proxy serves them in front of the site. Point externalAssets at those and they are skipped:

htmlPages({ externalAssets: ['/su/'] })

A trailing slash makes an entry a directory prefix — '/su/' covers /su/alert.js — and anything else has to match the URL exactly, so '/su' will not quietly swallow /super.js. Query strings and hashes are ignored when matching.


Dev server

vite dev gives you the real site, not an approximation:

  • Pages render on request through Vite's SSR module runner — edit a page, its data(), or any imported module and reload.
  • Dynamic routes render on demand. Visit /blog/anything and blog/[slug].ht.js renders with params.slug = 'anything' — no need to list every param in generateStaticParams while developing. (vite build still only emits the pages you list there.)
  • File changes inside your pages directory trigger an automatic full-reload in the browser.
  • Errors show a source-mapped code frame in the terminal pointing at the exact line in your page — the server stays alive while you fix it.
── PAGE RELOAD ERROR ───────────────────── src/index.ht.js:6:20

ReferenceError: title is not defined

> 6 │     head(title('My website')),
    │          ^

Fix the error and save again.
Watching for file changes...

Generated extras

404 page

Create src/404.ht.js and it's emitted as dist/404.html (the convention GitHub Pages, Netlify, and Cloudflare Pages all understand). No 404 page? A clean default is generated.

Sitemap

Set your site URL and dist/sitemap.xml is generated from all static routes, correctly escaped:

htmlPages({ site: 'https://example.com' })

RSS feed

htmlPages({
  rss: {
    site: 'https://example.com',
    title: 'My Blog',
    description: 'Latest posts',
    routePrefix: '/blog',   // which routes become feed items
  }
})

Produces dist/rss.xml with an item for every page under routePrefix.


Plugin options

htmlPages({
  pagesDir: 'src',
  cleanUrls: true,
  site: 'https://example.com',
  missingAssets: 'error',
  debug: false,
})

| Option | Default | Description | |--------|---------|-------------| | pagesDir | 'src' | Directory containing pages and assets | | pageExtensions | ['.ht.js', '.html.js', ...] | Which file suffixes are pages | | include | derived from pagesDir | Custom glob(s) for page discovery | | exclude | [] | Glob(s) to exclude from discovery | | root | Vite root | Override the project root | | cleanUrls | true | /about/index.html (/about) instead of /about.html | | site | — | Base URL; enables sitemap.xml | | rss | — | RSS config (site, title, description, routePrefix) | | missingAssets | 'error' | 'error' or 'warn' for broken asset references | | externalAssets | [] | Root-relative URL(s) another plugin serves; skipped by the missing-asset check | | mapOutputPath | — | (page) => string to customize output filenames | | generatedTypesDir | '.vite-plugin-html-pages/types' | Where generated page helper .d.ts files are written | | displayName | 'vite-plugin-html-pages' | Label used in console / overlay messages (e.g. [sitelo]) | | renderConcurrency | 8 | Pages rendered in parallel | | renderBatchSize | max(concurrency, 32) | Pages per render batch | | debug | false | Verbose logging of discovery, routing, and emission |

Performance

Large sites can raise the parallelism:

htmlPages({
  renderConcurrency: 16,
  renderBatchSize: 128,
})

Comparison

| Tool | What it is | |------|------------| | Astro | Component-based SSG with its own compiler and islands | | Next.js | Full React framework with SSR/ISR | | Eleventy | Template-language SSG (Nunjucks, Liquid, ...) | | vite-plugin-html-pages | Functions returning HTML, powered by plain Vite |

If you want components, hydration, and a framework — use a framework. If you want HTML files out of JavaScript functions with the Vite dev experience, this is the smallest tool that does the whole job.

Good fits

  • Marketing and landing pages
  • Blogs and documentation sites
  • HTML-first projects with a sprinkle of JS
  • API-driven static sites (with fetchWithCache)
  • Any site where "view source" should show exactly what you wrote

License

MIT