Pattern
UI islands.
React, Vue and Svelte can own interactive regions without owning the whole document. Nift generates the stable page and checked asset relationship; a normal frontend toolchain compiles the island.
A complete project shape
content/
products/configure.html
templates/
product.html
frontend/
vite.config.js
src/
configurator.jsx
public/
assets/
configurator.js
.nift/
package.json This example uses React and Vite, but the boundary is the same with Vue, Svelte, esbuild, Rollup or another browser build.
Nift provides useful HTML and the mount point
<article class="product-page">
<h1>Configure your workstation</h1>
<p>Choose memory and storage, then review the estimated total.</p>
<div id="configurator" data-currency="AUD">
<form>
<label>Memory <select disabled><option>16 GB</option></select></label>
<label>Storage <select disabled><option>512 GB</option></select></label>
<p>Enable JavaScript to configure this product interactively.</p>
</form>
</div>
</article>
<script type="module" src="@pathto('public/assets/configurator.js')"></script> The fallback can remain useful before hydration or when JavaScript fails. The page title, explanation, navigation and non-interactive content do not need a client framework.
Vite builds a stable entry file
// frontend/vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'node:path';
export default defineConfig({
plugins: [react()],
build: {
outDir: resolve(import.meta.dirname, '../public/assets'),
emptyOutDir: false,
rollupOptions: {
input: resolve(import.meta.dirname, 'src/configurator.jsx'),
output: {
entryFileNames: 'configurator.js',
chunkFileNames: 'chunks/[name]-[hash].js',
assetFileNames: 'chunks/[name]-[hash][extname]'
}
}
}
}); emptyOutDir: false matters when the bundler writes into a directory that also contains Nift-managed or manually maintained assets. A stable entry filename gives Nift a concrete path to verify while still allowing internal chunks to use content hashes.
Mount the island normally
// frontend/src/configurator.jsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Configurator } from './Configurator.jsx';
const mount = document.querySelector('#configurator');
if (mount) {
createRoot(mount).render(
<Configurator currency={mount.dataset.currency} />
);
} Nift does not participate in React rendering, state or hydration. It owns the generated document and the checked relationship to the bundle.
Make build order explicit
{
"scripts": {
"dev:islands": "vite build --watch --config frontend/vite.config.js",
"build:islands": "vite build --config frontend/vite.config.js",
"build:nift": "nift build",
"build": "npm run build:islands && npm run build:nift",
"build:release": "npm run build:islands && nift build --all"
}
} The bundle must exist before Nift evaluates @pathto. If the frontend build fails, stop; do not let Nift package a stale bundle from an earlier run.
Development loop
The simplest reliable development loop runs the Vite watcher and Nift watcher as sibling processes:
# terminal 1
npm run dev:islands
# terminal 2
nift build --auto Configure Vite's development build to write the artifact Nift references, or use a small process runner that starts both commands and stops both when one fails. Nift deliberately does not become the parent process for the frontend toolchain.
When the entry filename must be hashed
A fixed entry is the simplest relationship. If deployment policy requires every entry filename to be content-hashed, have the frontend build write a small Nift-facing manifest after it reads the bundler's native manifest. Build it first, then load the simple bridge file and interpolate its selected entry into the checked path:
// public/islands-manifest.json
{
"entry": "assets/configurator-CQ3eJ5.js"
} @json('public/islands-manifest.json', islands)
<script type="module"
src="@pathto('public/$[islands.entry]')"></script> Generate the bridge file; do not maintain the hash by hand. This avoids coupling the Nift template to a bundler-specific nested manifest shape. Both the bridge manifest and selected asset must exist before Nift builds, keeping cache-busting in the bundler and path verification in Nift.
Several islands and shared runtime
<div id="product-search"></div>
<div id="mini-cart"></div>
<script type="module" src="@pathto('public/assets/product-search.js')"></script>
<script type="module" src="@pathto('public/assets/mini-cart.js')"></script> Multiple islands may share chunks through the bundler. They can even use different frameworks, but that adds dependency and mental-model cost; do it because the repository genuinely needs it, not because Nift permits it.
Pass only the data the browser needs
Small scalar configuration can use data attributes. Larger public configuration can use a JSON script element. Never serialize server secrets into the document:
<script type="application/json" id="configurator-data">
{
"currency": "AUD",
"apiBase": "/api/catalog"
}
</script> When an island stops being an island
If one region grows into the entire authenticated route—with runtime routing, most content, data loading and state owned by the client application—letting Vite or an application framework own that route directly may be clearer. Keep Nift around the regions where its document composition and dependency model still earn their place.
Verification checklist
- Delete old frontend artifacts and prove the bundler recreates them.
- Run the frontend build before
nift build --all. - Confirm every
@pathtotarget and manifest-selected asset exists. - Load the page with JavaScript disabled and inspect fallback/accessibility behavior.
- Load it normally and check the browser console, network paths and chunk requests.
- Verify a failed frontend build cannot silently deploy a stale previous bundle.
Nift does not need framework-specific hydration directives to support selective interactivity. It needs an honest build order, a checked browser asset and a clear decision about what remains generated versus runtime-owned.