v12 migration
This guide covers breaking and notable changes when moving from domstack v11 to v12.
If you are migrating from top-bun, first follow the historical v11 guide at v11-migration.md.
Then apply the v12 changes below.
Runtime requirements
DOMStack v12 supports Node.js 22.18+ within the 22.x release line, and Node.js 24 or newer:
{
"engines": {
"node": "^22.18.0 || >=24.0.0"
}
}
Node.js 23 satisfied v11’s >=22 engine range but is not supported by v12.
Move development, CI, and deployment environments to Node.js 22.18+ within the 22.x release line, or Node.js 24+ before upgrading.
The minimum matches @domstack/sync and enables native TypeScript type stripping without an experimental flag.
Public type imports use @domstack/static/types.js
Runtime values remain available from @domstack/static. Public type-only imports must use the dedicated @domstack/static/types.js entry. The root package still includes declarations for its runtime API.
// Before v12
import type { LayoutFunction, PageFunction, DomStackOpts } from '@domstack/static'
// v12+
import type { LayoutFunction, PageFunction, DomStackOpts } from '@domstack/static/types.js'
Development server uses @domstack/sync
Watch/serve mode now uses @domstack/sync for the local development server.
This provides live reload, CSS injection, ghost mode, and the UI panel. If you were relying on BrowserSync-specific behavior or output, update your expectations around logs, access URLs, and reload handling.
v12 also adds --serve for a production-like, one-shot preview. It builds once and serves the destination without watch-mode filenames or live-reload injection:
domstack --serve --port 4000
--port is valid only with --serve. Do not combine --serve with --watch or --watch-only. Use --watch for development and --serve for final output, service-worker, and cache-lifecycle testing.
Default layout uses fragtml
The bundled default root.layout.js now uses fragtml for server-side HTML rendering.
If you rely on the bundled default layout, make sure your pages and child layouts return compatible values.
The v12 default layout accepts HTML strings and fragtml template results.
It does not render Preact or HTM VNodes.
If you already ejected or provide your own root layout, you do not have to change that layout for v12.
Keep the dependencies that your layout imports in your own package.json.
If you want to update to v12 while keeping the v11 Preact default layout, run domstack --eject on v11 before upgrading.
Then keep htm, preact, and preact-render-to-string installed after the upgrade.
If you want to migrate an ejected Preact/HTM layout to the v12 default style, update your layout imports and rendering code from Preact/HTM to fragtml.
// Before: src/root.layout.ts
import { html } from 'htm/preact'
import { render } from 'preact-render-to-string'
// After: src/root.layout.ts
import { html, raw, render } from 'fragtml'
Use raw(htmlString) when intentionally inserting already-rendered HTML.
Markdown output passed to a layout as children is one example.
Layout modules can export vars
Layouts can now export optional vars that are merged into the resolved variable cascade for every source-backed or generated page using that layout. Like page and global vars, layout vars may be an object, a sync function, or an async function.
This primarily supports generated pages. A generated page can select a layout but does not have an adjacent page directory or page.vars.ts file from which to inherit layout-specific defaults. Exporting those defaults from the layout lets source-backed and generated pages receive the same values without repeating them in every generated-page definition.
// src/layouts/article.layout.ts
export const vars = {
showSidebar: true,
pageType: 'article',
}
Precedence is:
page/frontmatter vars > page.vars.* > layout vars > global.vars > domstack defaults
Layout vars also let shared defaults, including manifest and service-worker policy values, participate in the normal variable cascade instead of being mixed into rendered output by each layout. If an existing layout manually merges defaults into the values it passes to a child layout or template, move those defaults to the layout’s exported vars so generated-page definitions, page vars, and frontmatter can override them consistently.
This is additive for most sites. If a layout module already exported a named vars value for another purpose, that value will now participate in page variable resolution. Rename that export if it was not intended as layout defaults.
Declare nested layouts with parentLayout
Layouts can now export a static parentLayout name instead of importing and invoking their parent render function.
Pages still select the innermost layout through vars.layout.
parentLayout is an optional named string export, not a field in vars, an import path, or a callback.
Omitting it leaves the selected layout without a parent; a non-root layout is not automatically wrapped by root.
See the layout module reference and nested-layout declaration contract for name resolution, validation, rendering order, and rebuild behavior.
// article.layout.ts
export const parentLayout = 'root'
export const vars = { showSidebar: true }
export default function articleLayout ({ children }) {
return `<article>${children}</article>`
}
DOMStack renders from the page outward and passes intermediate values unchanged between layouts. All renderers receive the final resolved vars, with precedence:
builder/frontmatter > page.vars.* > inner layout vars > outer layout vars > global vars > defaults
Styles and client entry points are included automatically in default, global, outer-layout, inner-layout, page order. Watch mode follows the resolved chain and each layout’s ordinary imported helpers for both source-backed and generated pages. Missing parents, invalid parent names, and cycles fail the build.
Existing single layouts and manual function composition from earlier versions continue to work.
Migrate manually nested layouts to parentLayout so DOMStack can follow their full dependency chain for reliable rebuilds and manage their assets automatically.
To migrate a manually nested layout, replace its parent call with parentLayout, return only its own wrapper, and remove explicit imports of the parent’s layout CSS and client.
Move manually merged defaults to the appropriate layout’s vars export.
Do not retain both the parent function call and parentLayout, because that renders the parent twice.
Manual composition remains responsible for its own vars, asset imports, and argument forwarding.
Keep layout dependencies explicit
DOMStack only installs dependencies for its bundled defaults. Your project is responsible for any packages imported by pages, layouts, globals, or browser clients.
If your ejected layout or server-side pages import htm/preact, preact, or preact-render-to-string, keep those packages in your own package.json.
If you migrate those server-side templates to fragtml, replace those dependencies with fragtml.
JSX runtime is opt-in
Client .jsx and .tsx bundles are still supported through esbuild.
DOMStack no longer configures Preact as the default JSX runtime.
If your browser client code uses JSX or TSX, install the runtime you want and configure it with esbuild.settings.
For Preact:
npm install preact
// src/esbuild.settings.ts
import type { BuildOptions } from '@domstack/static/types.js'
export default function esbuildSettingsOverride (settings: BuildOptions): BuildOptions {
return {
...settings,
jsx: 'automatic',
jsxImportSource: 'preact',
}
}
For React:
npm install react react-dom
// src/esbuild.settings.ts
import type { BuildOptions } from '@domstack/static/types.js'
export default function esbuildSettingsOverride (settings: BuildOptions): BuildOptions {
return {
...settings,
jsx: 'automatic',
jsxImportSource: 'react',
}
}
mine.css v11 and optional CSS layers
DOMStack v12 updates its bundled default stylesheet from mine.css v10 to v11. Sites that use the bundled default layout and stylesheet receive the update without changing their source. The migration steps below apply only when a project imports mine.css directly, has ejected DOMStack’s defaults, or has customized behavior removed by mine.css v11. Review the complete mine.css v11 migration guide in those cases.
mine.css v11 is CSS-only. Its package root now resolves to the main stylesheet, and its JavaScript theme switcher is no longer published. Direct consumers should replace the old deep import with the package root:
@import 'mine.css';
Direct or ejected consumers should remove JavaScript imports of mine.css or mine.css/dist/theme-switcher.js, calls to toggleTheme(), stored theme state, theme controls, and .light-mode or .dark-mode rules.
Custom root layouts that use mine.css should include <meta name="color-scheme" content="light dark"> and use prefers-color-scheme for application-specific dark styles.
If an ejected stylesheet uses Highlight.js, load a light theme normally and a dark theme conditionally instead of applying one dark theme in both modes.
The optional mine.css layout remains a separate import. DOMStack’s default stylesheet imports it explicitly, and ejected sites that want the same layout should continue to do so.
Optional cascade-layer pattern
The main mine.css stylesheet places its framework rules in the low-priority mine layer.
DOMStack’s default stylesheet imports mine.css normally and places its optional layout and Highlight.js sidecars in domstack.default:
@import 'mine.css';
@import 'mine.css/dist/layout.css' layer(domstack.default);
@import 'highlight.js/styles/github.css' layer(domstack.default);
@import 'highlight.js/styles/github-dark-dimmed.css' layer(domstack.default) (prefers-color-scheme: dark);
Custom stylesheets do not have to use layers.
Unlayered author rules override normal declarations in mine and domstack.default.
Projects that want explicit layers can let each DOMStack stylesheet declare only its own scope:
/* global.css */
@layer domstack.global {
/* Site-wide rules */
}
/* article.layout.css */
@layer domstack.layout {
/* Layout rules */
}
/* style.css */
@layer domstack.page {
/* Page rules */
}
No stylesheet needs to enumerate the other scopes. DOMStack loads default, global, layout, and page styles in that order, so the layers are first encountered with the corresponding low-to-high precedence. This is a recommended organizational pattern, not a migration requirement.
mine.css v11 intentionally changes typography, forms, tables, media framing, motion, focus treatment, and the optional .mine-layout width.
Direct, ejected, or heavily customized consumers should review those surfaces against the upstream migration guide.
The distributed CSS uses native CSS nesting, and mine.css requires Node.js 22 or newer and npm 10 or newer for installation.
CLI --target moved to esbuild.settings.*
The domstack --target / domstack -t CLI flag has been removed in v12. Configure esbuild targets in
esbuild.settings.* instead.
// src/esbuild.settings.ts
import type { BuildOptions } from '@domstack/static/types.js'
export default function esbuildSettings (settings: BuildOptions): BuildOptions {
return {
...settings,
target: ['es2022', 'chrome120', 'firefox121', 'safari17'],
}
}
DOMStack does not set a rolling “modern browser” target by default. If your project needs specific syntax lowering, set explicit esbuild targets in this settings file. See esbuild’s target docs for accepted values.
Default esbuild asset loaders
v12 adds default loaders for assets imported by browser bundles. This is a breaking change for projects that import these file types or replace esbuildSettings.loader.
| Loader | Extensions | v12 behavior |
|---|---|---|
dataurl |
.png, .jpg, .jpeg, .gif, .svg, .webp, .avif |
Embed the imported asset in its bundle |
file |
.ico, .woff, .woff2, .ttf, .eot, .otf |
Emit a separate file and return its public URL |
Images are embedded regardless of size unless you override their loader. If a large imported image should remain a separate file, set its extension to file.
When adding loaders, merge the existing object instead of replacing it:
// src/esbuild.settings.ts
import type { BuildOptions } from '@domstack/static/types.js'
export default function esbuildSettings (settings: BuildOptions): BuildOptions {
return {
...settings,
loader: {
...settings.loader,
'.wasm': 'file',
},
}
}
Replacing loader without spreading settings.loader intentionally discards DOMStack’s defaults. Review imported image and font output after upgrading, especially if URLs or bundle sizes are deployment-sensitive.
Global data subscriptions and page introspection additions
global.data.ts is now the only public hook that receives the source-backed PageData[] collection.
It returns named top-level values, and downstream pages, layouts, templates, and generated-page factories receive only values they explicitly declare through dataDeps.
Subscribed values arrive through a separate data argument and are not merged into vars.
Pages declare dataDeps in frontmatter, an adjacent page.vars.ts, or a TypeScript page’s vars export.
Layouts declare them in their vars export.
Each layout receives only the keys it declares, independently of its parent and page.
The output’s watch subscriptions include the union from the page and every layout in its parentLayout chain, so children do not repeat ancestor declarations.
Templates and *.pages.ts factories use a named export const dataDeps = [...] because they do not have consumer vars.
While global.data.ts is resolving, renderInnerPage() can render a page without its own subscriptions even if its layouts subscribe to derived data.
renderFullPage() cannot run until data is ready if the page or any layout in its chain subscribes.
Keep focused consumer data types beside the complete type returned by global.data.ts.
Export those contracts for pages, layouts, templates, and factories instead of making each consumer reconstruct a Pick<GlobalData, ...> selection.
Use satisfies DataDeps<ConsumerData> from @domstack/static/types.js to check declaration names; readonly arrays and as const tuples are supported.
The factory’s data type and its inline pages’ data type can differ: PagesFunction<PageVars, Content, FactoryVars, FactoryData, PageData>.
Source collation can also be typed explicitly with GlobalDataFunction<Result, SourceVars, SourceContent> and GlobalDataFunctionParams<SourceVars, SourceContent>.
Manual composition remains supported: declare every key the called renderers need on the composing layout and forward its data argument.
Prefer parentLayout so DOMStack discovers ancestor subscriptions and manages the complete layout chain automatically.
Static imports still track manually called parents and helpers, and changes to imported global.data.* helpers recompute subscribed values.
Invalid declarations, missing keys, undeclared reads, and premature data access fail with DomStackDataError (DOM_STACK_ERROR_DATA), preserved inside the build’s aggregate errors.
Watch mode retains the previous successful dependency state on failure and retries the full page phase on the next page build.
This replaces the earlier v12 prerelease behavior that passed pages broadly and stamped all global data into every consumer’s vars.
Update any prerelease-based code that reads global data from vars or accepts pages outside global.data.ts.
PageData also has new helpers for collection processing:
readMarkdownContent()reads the Markdown source body without frontmatterrenderInnerPage()renders page content without its layoutrenderFullPage()renders the complete page
Prefer page.vars for variable access during collection processing so your code respects the full cascade of defaults and overrides.
This object is cached and shallow-frozen, merging global → each layout (outermost to innermost) → page → builder, with later values overriding earlier ones.
Treat it as read-only rather than mutating it during collection processing.
Page and layout renderers receive these resolved values through their vars argument.
See Page data and introspection for examples and rendering guidance.
For cases that specifically require inspecting individual contributions, PageData.layoutVars is an escape hatch exposing Array<{ name: string, vars: Partial<T> }>.
Entries are ordered outermost to innermost, preserving each layout’s values even when another source overrides them, and each entry’s vars excludes dataDeps.
Direct layer access bypasses the cascade; use it for inspecting a value’s source rather than normal application variable access.
See Inspecting layout variables for an example.
Markdown and frontmatter dependency updates
v12 updates the Markdown and frontmatter stack to markdown-it 15, markdown-it-deflist 4, and js-yaml 5. It also enables markdown-it-github-alerts, so GitHub-style [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], and [!CAUTION] blockquotes produce alert markup.
Most Markdown pages require no source changes. Sites should compare rendered output when they:
- Customize the parser through
markdown-it.settings.ts - Register third-party markdown-it plugins
- Depend on exact generated HTML in CSS, tests, or content transforms
- Use definition lists or unusual YAML frontmatter values
The alert plugin provides markup, not site-specific presentation. Import its styles or provide equivalent rules if you use alert blocks. See Markdown settings for DOMStack’s default plugin list and override API.
Generated pages
v12 adds *.pages.ts modules for creating normal layout-backed pages from data. A module may export one definition, an array, a sync or async factory, or an async iterable.
// src/archive.pages.ts
import type { PagesFunction } from '@domstack/static/types.js'
type ArchiveVars = { layout: string, year: number }
type ArchiveData = { archiveYears: number[] }
export const dataDeps = ['archiveYears']
const archivePages: PagesFunction<ArchiveVars, string, Record<string, never>, ArchiveData> = ({ data }) => {
return data.archiveYears.map(year => ({
outputName: `archive/${year}/index.html`,
vars: { layout: 'root', year },
children: `<h1>${year}</h1>`,
}))
}
export default archivePages
Generated pages use global and layout assets and participate in output conflict detection, drafts, manifests, and progressive watch rebuilds.
They do not have page-local style.css, client.ts, or *.worker.ts assets because they do not own a source page directory.
Factories receive global vars, their declared global data, and metadata for their own *.pages.ts file.
They do not receive raw source-backed or generated pages.
Likewise, results.siteData.pages remains source discovery data and does not include generated pages.
See Generated pages for all export forms, types, and lifecycle details.
Programmatic test builds
v12 adds the testBuild() helper for isolated programmatic build tests. It creates a temporary destination, runs a build, and returns the build results plus the temporary path and a cleanup function.
// test/site.test.ts
import assert from 'node:assert/strict'
import test from 'node:test'
import { testBuild } from '@domstack/static'
test('builds the home page', async () => {
const build = await testBuild('./src')
try {
const html = await build.readOutput('index.html')
assert.match(html, /Hello/)
} finally {
await build.cleanup()
}
})
This is additive.
Existing tests that construct DomStack directly can continue to do so.
See Programmatic test builds for the complete return shape and repository examples.
DOMStack manifest and service workers
v12 adds first-class site service-worker builds and an unstable-preview DOMStack manifest pipeline for static caching.
The service-worker entrypoint and build API are stable. The DOMStack manifest API is not: its option shapes, schema, generated output, and runtime semantics may change outside of a major version while it is validated with real PWA use cases. Pin @domstack/static to an exact version if you rely on the manifest contract.
Add a site service worker
A service worker is the browser entrypoint that installs cache contents, intercepts requests, and applies offline/update behavior. DOMStack now discovers one service-worker.* source module anywhere under src and bundles it to the stable public URL /service-worker.js.
DOMStack does not register the service worker for you. Your app owns registration timing, update prompts, local-development opt-outs, reset/recovery behavior, route filtering, offline fallback behavior, and runtime cache policy.
The service worker can read DOMStack’s build-time browser defines:
| Define | Value |
|---|---|
process.env.DOMSTACK_MANIFEST_URL |
Standard public URL for the built-in manifest, /domstack-manifest.json |
process.env.DOMSTACK_MANIFEST_VERSION |
Finalized manifest version in /service-worker.js; "" in other bundles |
process.env.DOMSTACK_MANIFEST_ENABLED |
"true" for one-shot builds with an enabled manifest pipeline, "false" when disabled or in watch mode |
process.env.DOMSTACK_SERVICE_WORKER_URL |
Public URL of the site service worker, usually /service-worker.js, or "" when no service worker is present |
process.env.DOMSTACK_SERVICE_WORKER_SCOPE |
Registration scope for the site service worker, usually /, or "" when no service worker is present |
Configure the manifest in a settings module
Add one domstack-manifest.settings.* module anywhere under src, using the same supported module extensions as other DOMStack settings files. The settings module enables the manifest pipeline and is the primary place to select application vars, define root policy, filter entries, and register build hooks.
Use manifestVars to expose selected page, layout, global, or default vars on manifest entries. Use policy for values shared by the whole application. Use includeEntry(entry) and exclude to filter final public output entries.
import type { DomstackManifestOptions } from '@domstack/static/types.js'
const settings = {
manifestVars: ['offline', 'precache'],
policy: {
offlineFallbackUrl: '/offline/',
},
exclude: ['admin/**', 'blog/**', '**/*.map'],
includeEntry (entry) {
if (entry.kind === 'metadata') return false
if (entry.kind === 'sourcemap') return false
if (entry.manifestVars?.offline === false) return false
if (entry.manifestVars?.precache === false) return false
return true
},
} satisfies DomstackManifestOptions
export default settings
A settings module returns results.domstackManifest and runs manifest hooks, but does not write public JSON unless writing is explicitly enabled.
Inject finalized policy into the service worker
The primary service-worker integration point is hooks.manifestBuilt.
This hook receives the finalized manifest before the final /service-worker.js bundle is emitted.
Use context.defineServiceWorkerConstant() to inject JSON-serializable policy directly into the service-worker bundle.
import type {
DomstackManifestBuiltHookContext,
} from '@domstack/static/types.js'
export async function injectServiceWorkerPolicy (
context: DomstackManifestBuiltHookContext
): Promise<void> {
context.defineServiceWorkerConstant('__APP_CACHE_POLICY__', {
version: context.manifest.version,
precacheEntries: context.manifest.entries.map(entry => ({
url: entry.url,
revision: entry.urlRevisioned ? null : entry.revision,
})),
})
}
Bundling the finalized URL/revision list into the service worker is the standard precache workflow: the installed worker receives policy for exactly the outputs produced by the same build. If you intentionally need a separate custom artifact, write it from the hook with context.writeFile().
Optionally write a public manifest
Most service-worker integrations only need the settings module and manifestBuilt hook. Write /domstack-manifest.json when another runtime or deployment tool needs to fetch the manifest:
domstack --domstackManifest
Programmatic builds can opt in with domstackManifest: true; the object form is available for callers that need to override settings or writing behavior.
Use --serve for PWA testing
Watch mode intentionally does not write or return the domstack manifest. It still rebuilds the site service worker, but watch-mode output is not representative of production cache invalidation.
Use --serve when testing PWA install/update/offline lifecycle behavior:
domstack --serve
Add --domstackManifest only if you also want to serve the public domstack-manifest.json file while debugging:
domstack --serve --domstackManifest
Avoid circular manifest dependencies
The built-in manifest file is never included in its own entries.
Site service workers are also omitted from manifest entries.
This lets DOMStack inject the finalized manifest.version into /service-worker.js without making the manifest hash depend on the service-worker hash.
Migration checklist
- Run development, CI, and deployment builds on Node.js 22.18+ within the 22.x release line, or Node.js 24+. Do not use Node.js 23.
- If you import public types from
@domstack/static, update those imports to@domstack/static/types.js. - If you rely on BrowserSync-specific dev-server behavior, test watch mode with
@domstack/sync. - If you use the new
--servepreview, keep it separate from watch modes and use--portonly with--serve. - If you rely on the bundled default layout, make sure pages and child layouts return HTML strings or
fragtmltemplate results, not Preact or HTM VNodes. - If any layout module already exports a named
varsvalue, confirm it should now act as layout defaults. - If you want to keep the v11 Preact default layout, eject on v11 before upgrading to v12.
- If your ejected layout or server-side pages still import
htm/preact,preact, orpreact-render-to-string, keep those dependencies in your ownpackage.json. - If you want your ejected server-side layout to match the v12 default, migrate its templates to
fragtmland installfragtml. - If you use
.jsxor.tsxbrowser clients, add anesbuild.settingsfile that configures your JSX runtime. - If you use
domstack --targetordomstack -t, move that target list toesbuild.settings.*. - Review browser imports of images, icons, and fonts against v12’s default esbuild loaders.
- Merge
settings.loaderwhen adding custom esbuild loaders unless you intentionally want to discard DOMStack’s defaults. - If you use Preact browser clients, keep
preactin your project dependencies. - If you directly consume mine.css or ejected DOMStack’s defaults, read the mine.css v11 migration guide.
- In direct or ejected stylesheets, replace
@import 'mine.css/dist/mine.css'with@import 'mine.css'and keep optional sidecars explicit. - In direct or ejected clients, remove
toggleTheme(), theme-switcher imports, persisted theme state, theme controls, and light/dark mode classes. - Add
<meta name="color-scheme" content="light dark">to custom root layouts that use mine.css and useprefers-color-schemefor dark styles. - If desired, organize custom global, layout, and page rules in their corresponding optional
domstack.*layers. - In ejected stylesheets, load light and dark syntax-highlighting themes with matching
prefers-color-schemebehavior. - If installing mine.css directly, confirm the environment uses Node.js 22+ and npm 10+ and target browsers support native CSS nesting.
- Visually check mine.css surfaces that the project directly customizes.
- Move any global-data reads out of
vars, declare the required top-level keys indataDeps, and read them fromdata. - Move any page-collection processing from pages, layouts, templates, or
*.pages.tsfactories intoglobal.data.ts. - If you customize Markdown, use parser plugins, or snapshot rendered HTML, verify output with the v12 Markdown and YAML dependency versions.
- If you use GitHub alert blocks, import alert styles or provide equivalent site styles.
- Treat
PageData.varsas read-only and use the new Markdown and rendering helpers when processing page collections. - If adopting
*.pages.ts, verify output names, declared data dependencies, and the lack of page-local assets. - If adopting the manifest preview, pin an exact DOMStack prerelease and test service-worker lifecycle behavior with
--serve.