Full-stack meta-framework for Nix.js — file-based routing, SSG, SSR, ISR, islands, and SPA-like navigation. Zero extra runtime dependencies on the client: Nix.js stays at ~14KB.
Nix Kit is a meta-framework built on top of Nix.js. It brings conventions similar to Next.js App Router to Nix.js:
src/app/page.tsfor pagessrc/app/page.data.tsfor loaderssrc/app/page.action.tsfor server actionssrc/app/layout.tsfor layoutssrc/app/route.tsfor API endpoints
The key difference is the runtime cost: Nix.js has no virtual DOM, so islands hydrate individual signals instead of full component trees. The result is a much smaller client bundle.
npm install @deijose/nix-js @deijose/nix-js-kit
# or
bun add @deijose/nix-js @deijose/nix-js-kit// src/app/page.data.ts
import type { PageDataLoad } from "@deijose/nix-js-kit";
export const load: PageDataLoad = async () => {
return { title: "Hello Nix Kit" };
};// src/app/page.ts
import { html, signal } from "@deijose/nix-js";
import type { PageProps } from "@deijose/nix-js-kit";
import { load } from "./page.data.ts";
export default function HomePage({ data }: PageProps<typeof load>) {
const liked = signal(false);
return html`
<article>
<h1>${data.title}</h1>
<button @click=${() => (liked.value = !liked.value)}>
${() => (liked.value ? "★ Liked" : "☆ Like")}
</button>
</article>
`;
}At build time, nix-js-kit runs the loader and renders the page to static HTML using renderToString.
After installing, the nix-js-kit binary is available in your project:
nix-js-kit build
nix-js-kit dev
nix-js-kit preview
nix-js-kit start
nix-js-kit adapter vercel
nix-js-kit adapter netlify
nix-js-kit adapter bun
nix-js-kit adapter nodeBy default it looks for src/app/ and src/islands/ and writes to dist/:
nix-js-kit build
# → dist/index.html
# → dist/_nix-js/entry-client.js (after bundling the generated entry)Run the dev server with rebuild-on-change:
nix-js-kit devIf you have a vite.client.config.ts, the client hydration bundle is built automatically. You can still pass an explicit config with --client-config <path>.
Serve the production build:
nix-js-kit build
nix-js-kit previewRun the SSR server (renders pages on demand):
nix-js-kit build # generate or update the client bundle
nix-js-kit startEnable ISR with a cache directory and default TTL:
nix-js-kit start --cache-dir .nix-js/cache --default-revalidate 60Options:
| Flag | Default | Description |
|---|---|---|
-r, --root <dir> |
cwd |
Project root |
-a, --app <dir> |
src/app |
Pages directory relative to root |
-i, --islands <dir> |
src/islands |
Islands directory relative to root |
-o, --out <dir> |
dist |
Output directory relative to root |
-p, --port <number> |
3000 |
Server port |
-h, --host <address> |
127.0.0.1 |
Server host |
-l, --lang <lang> |
es |
HTML lang attribute |
--hydrate-import <spec> |
@deijose/nix-js-kit/island |
Import path for hydrateIslands in generated entry |
--client-config <path> |
vite.client.config.ts (auto-detected) |
Vite config used to build the client bundle in dev mode |
- Static site generation (SSG) from
src/app/file conventions. - File-based route scanner — maps
page.tsfiles to URLs. - Dynamic routes with
generateStaticParams— generate static HTML for[slug]and[...slug]routes, with SSR fallback for slugs not generated at build time. - Route groups
(marketing)— shared layouts without affecting the URL path. - Layout chain — nested
layout.tsfiles wrap pages automatically. - SSR runtime —
nix-js-kit startrenders pages on demand and serves static assets. - ISR — incremental static regeneration with disk cache and TTL (
revalidate). - Streaming boundaries —
loading.tsshells render instantly and fetch real content from/__nix-js/render. - SPA-like navigation — built-in client router intercepts internal links, fetches page bodies, and swaps
#appwithout full reloads. - Attribute interpolation plugin — write natural
href="/blog/${slug}"and the kit transforms it into a single Nix.js interpolation at build time. - Vite plugin —
nixJsKit()gives a Vite-native dev server with SSR and island entry generation. - Vercel, Netlify, Bun and Node adapters for deployment.
- Custom error pages —
src/app/404.page.tsandsrc/app/500.page.tsare rendered for 404/500 responses in SSG, SSR, and all adapters. - Server actions — define
page.action.tsfiles next topage.tsand call them from the client withnixAction()orcallAction(). - Scoped actions — actions are registered per page path, so names only collide if they are in the same route.
- Progressive enhancement — actions work from plain HTML forms without JavaScript.
renderToStringfor Nix.js templates without touching the Nix.js core.- Happy DOM as a build-time dependency only — the Nix.js client bundle stays dependency-free.
- Islands via
island()helper — mark interactive components and hydrate them on the client withhydrateIslands. - Auto island scan —
build()scanssrc/islands/and generates the client hydration entry for you. - Document shell with serialized loader data (
<script id="nix-js-data">). - CLI (
nix-js-kit build/nix-js-kit dev/nix-js-kit preview/nix-js-kit start).
- Automatic attribute interpolation — no more manual workarounds for
href="/blog/${slug}". The kit rewrites partial interpolations into valid Nix.js single interpolations during build, dev, start, and preview. - Client router in the bundle — the SPA router lives in the generated client entry (
/_nix-js/entry-client.js) instead of being inlined into every page, keeping the HTML clean and the routing code cacheable. - SSR fallback in preview —
previewnow renders dynamic routes on demand when a static file is missing, so slugs work even withoutgenerateStaticParams. - Auto client bundle build — when
vite.client.config.tsis present,buildanddevbuild the hydration bundle automatically; no--client-configflag is required. - No server paths in HTML — the serialized action registry only exposes action names per page (
{"/contact":["subscribe"]}), never file system paths or implementation details.
| Version | Focus |
|---|---|
| v0.1 | SSG + file-based routing |
| v0.2 | Islands, data loading, actions, API routes |
| v0.3 | CLI + dev server |
| v0.4 | generateStaticParams, route groups, preview server |
| v0.5 | SSR runtime + adapter-node |
| v0.6 | Vite plugin + DX improvements |
| v0.7 | Vercel adapter + DX improvements |
| v0.8 | Netlify adapter + Bun adapter |
| v0.9 | Server actions ✅ |
| v1.0 | Stabilization: test suite, error handling ✅, Node adapter ✅, and action DX ✅ |
| v1.1 | Streaming boundaries + ISR ✅ |
| v1.2 | Interpolation plugin, SPA router, preview SSR fallback ✅ |
Renders a Nix.js template to an HTML string in Node.js.
import { renderToString } from "@deijose/nix-js-kit";
import HomePage from "./src/app/page";
const body = await renderToString(() => HomePage({ data: { title: "Hi" } }));Wraps rendered HTML in a full document shell with <script id="nix-js-data">.
import { documentShell } from "@deijose/nix-js-kit";
const html = documentShell({
title: "My Page",
body,
data: { title: "My Page" },
clientEntry: "/_nix-js/entry-client.js",
});Create an interactive component in src/islands/:
// src/islands/LikeButton.ts
import { html, signal } from "@deijose/nix-js";
export default function LikeButton({ postId }: { postId: string }) {
const liked = signal(false);
return html`
<button @click=${() => (liked.value = !liked.value)}>
${() => (liked.value ? "★ Liked" : "☆ Like")}
</button>
`;
}Mark it as an island in a page:
// src/app/page.ts
import { html, island } from "@deijose/nix-js-kit";
import LikeButton from "../islands/LikeButton";
export default function HomePage() {
return html`
<article>
<h1>Hello</h1>
${island("LikeButton", LikeButton, { postId: "123" }, "load")}
</article>
`;
}Hydrate it on the client. You can write the entry by hand:
// src/entry-client.ts
import { hydrateIslands } from "@deijose/nix-js-kit/island";
import LikeButton from "./islands/LikeButton";
hydrateIslands({ LikeButton });…or let build() generate it for you by scanning src/islands/ (see
Auto island scan below). Each .ts file becomes an island
whose registry name is its path relative to islandsDir
(nav/MobileMenu.ts → "nav/MobileMenu").
Directives:
| Directive | Hydration trigger |
|---|---|
load |
Immediately |
idle |
requestIdleCallback |
visible |
IntersectionObserver |
Scans src/app/ and generates the full static site in dist/. You can call it
from code or use the nix-js-kit build CLI (see CLI).
import { build } from "@deijose/nix-js-kit";
await build({
appDir: "./src/app",
outDir: "./dist",
clientEntry: "/_nix-js/entry-client.js",
// Optional: auto-generate the hydration entry from src/islands/
islandsDir: "./src/islands",
generatedEntry: "./.nix-js/entry-client.ts",
});The scanner recognizes:
| File | URL | Notes |
|---|---|---|
src/app/page.ts |
/ |
Home page |
src/app/about/page.ts |
/about |
Static page |
src/app/blog/[slug]/page.ts |
/blog/:slug |
Dynamic route (requires generateStaticParams) |
src/app/[...slug]/page.ts |
/:slug* |
Catch-all route (requires generateStaticParams) |
src/app/(marketing)/about/page.ts |
/about |
Route group (ignored in URL, can add layout) |
src/app/layout.ts |
all children | Root layout |
src/app/blog/layout.ts |
/blog/* |
Nested layout |
src/app/(marketing)/layout.ts |
/pricing, /features |
Group layout |
src/app/404.page.ts |
error | Custom 404 page (SSG, SSR, adapters) |
src/app/500.page.ts |
error | Custom 500 page (SSG, SSR, adapters) |
Dynamic routes are skipped during SSG unless the page exports a
generateStaticParams function. It returns an array of param objects, one per
static HTML file to generate:
// src/app/blog/[slug]/page.ts
import { html } from "@deijose/nix-js";
import type { PageProps, GenerateStaticParams } from "@deijose/nix-js-kit";
import { load } from "./page.data.ts";
export const generateStaticParams: GenerateStaticParams = async () => {
return [{ slug: "hello-world" }, { slug: "nix-js-kit" }];
};
export default function BlogPostPage({ data, params }: PageProps<typeof load>) {
return html`
<article>
<h1>${data.title}</h1>
<p>Slug: ${params.slug}</p>
</article>
`;
}// src/app/blog/[slug]/page.data.ts
import type { PageDataLoad } from "@deijose/nix-js-kit";
export const load: PageDataLoad = async ({ params }) => {
return { title: `Post: ${params.slug}` };
};Running nix-js-kit build then produces:
dist/blog/hello-world/index.html
dist/blog/nix-js-kit/index.html
Catch-all routes use a string array for the spread param:
export const generateStaticParams = async () => {
return [{ slug: ["docs", "intro"] }]; // -> /docs/intro
};Create a page.action.ts file next to a page.ts and export async functions.
They run on the server and can be called from the client with callAction() or
nixAction():
// src/app/contact/page.action.ts
export async function submitContact(data: { name: string; email: string }) {
// validate, write to DB, send email, etc.
return { ok: true };
}// src/app/contact/page.ts or any island
import { nixAction } from "@deijose/nix-js-kit/action";
const contact = nixAction("submitContact", { page: "/contact" });
// inside a template
html`
<form @submit=${(e: Event) => {
e.preventDefault();
contact.submit({ name: "Ada", email: "ada@example.com" });
}}>
<input name="name" />
<input name="email" />
<button type="submit" disabled=${() => contact.pending.value}>
${() => (contact.pending.value ? "Sending..." : "Send")}
</button>
</form>
${() => contact.error.value ? html`<p>${contact.error.value.message}</p>` : null}
${() => contact.data.value ? html`<p>Sent!</p>` : null}
`nixAction returns a reactive handle with:
submit(input)— calls the action and updates the signals.pending— signal that istruewhile the action is running.error— signal with the last error, ornull.data— signal with the last successful result, ornull.
The page option scopes the action to a specific route, avoiding name
collisions between different page.action.ts files. If you omit it, the
framework falls back to searching all scanned actions by name.
For lower-level control, use callAction directly:
import { callAction } from "@deijose/nix-js-kit/action";
const result = await callAction("submitContact", { name: "Ada", email: "ada@example.com" }, { page: "/contact" });Actions also work without JavaScript. Add hidden fields to a plain HTML form
and POST to /__nix-js/actions:
<form action="/__nix-js/actions" method="POST">
<input type="hidden" name="__nix_action_name" value="submitContact" />
<input type="hidden" name="__nix_action_page" value="/contact" />
<input name="name" />
<input name="email" />
<button type="submit">Send</button>
</form>The server runs the action and redirects back to the referring page (or to the
string returned by the action). If the client sends Accept: application/json,
the result is returned as JSON instead.
The framework exposes the POST /__nix-js/actions endpoint in every server mode
(dev, preview, start and all deployment adapters). The action name is
resolved against the scanned page.action.ts modules and its return value is
serialized as JSON.
Folders whose name is wrapped in parentheses are ignored in the URL but can
hold a layout.ts that applies to all their children:
src/app/
├── (marketing)/
│ ├── layout.ts
│ ├── pricing/
│ │ └── page.ts # -> /pricing
│ └── features/
│ └── page.ts # -> /features
This is useful for shared layouts that don't affect the public path, such as a marketing shell that differs from a dashboard shell.
Create optional src/app/404.page.ts and src/app/500.page.ts files to customize
the response when a route is missing or when a page fails to render:
// src/app/404.page.ts
import { html } from "@deijose/nix-js";
export default function NotFoundPage() {
return html`
<article>
<h1>404</h1>
<p>Page not found.</p>
<a href="/">Back home</a>
</article>
`;
}// src/app/500.page.ts
import { html } from "@deijose/nix-js";
export default function ErrorPage() {
return html`
<article>
<h1>500</h1>
<p>Something went wrong.</p>
<a href="/">Back home</a>
</article>
`;
}The framework renders these pages:
- During
nix-js-kit buildasdist/404.htmlanddist/500.html. - During
nix-js-kit startand in the Vite plugin for unmatched routes and render errors. - In every deployment adapter (
vercel,netlify,bun,node) for unmatched routes and SSR render failures.
Error pages receive the same PageProps as regular pages and can export their own 404.page.data.ts or 500.page.data.ts loaders.
nix-js-kit start runs a Node HTTP server that renders pages on demand,
matching the request URL against the scanned routes and running loaders with
params and search params. Static files are served from the output directory
first, so the client bundle and other assets keep working:
nix-js-kit build # build the client bundle and any static files
nix-js-kit start # SSR server on http://127.0.0.1:3000You can also use the lower-level API to embed the SSR server in a custom Node app:
import { createSsrServer } from "@deijose/nix-js-kit";
const ssr = await createSsrServer({
appDir: "./src/app",
publicDir: "./dist",
clientEntry: "/_nix-js/entry-client.js",
port: 3000,
});
await ssr.listen();The official Vite plugin gives you a Vite-native dev server with SSR rendering and automatic island entry generation:
import { defineConfig } from "vite";
import { nixJsKit } from "@deijose/nix-js-kit/vite";
export default defineConfig({
plugins: [nixJsKit()],
});Then run the Vite dev server:
npx viteThe plugin scans src/app/, writes .nix-js/entry-client.ts and renders every
page on demand. For production, keep using nix-js-kit build to generate static
HTML and the client bundle.
Deploy to Vercel with the built-in adapter. First build the site, then generate the Vercel output:
nix-js-kit build
nix-js-kit adapter vercelThis produces a .vercel/output directory that includes:
static/— the static files fromdist/.functions/__nix-js-kit.func/index.js— a bundled SSR function for unmatched routes.config.json— Vercel Build Output API v3 routing config.
You can also use the adapter programmatically:
import { vercelAdapter } from "@deijose/nix-js-kit/adapters/vercel";
await vercelAdapter.build({
root: process.cwd(),
appDir: "src/app",
islandsDir: "src/islands",
outDir: "dist",
clientEntry: "/_nix-js/entry-client.js",
lang: "es",
});Deploy to Netlify with the built-in adapter:
nix-js-kit build
nix-js-kit adapter netlifyThis produces:
netlify/functions/__nix-js-kit.mjs— bundled SSR function for Netlify Functions v2.netlify.toml— redirects unmatched routes to the function.
The static files stay in dist/ and are served directly by Netlify. Programmatic usage:
import { netlifyAdapter } from "@deijose/nix-js-kit/adapters/netlify";
await netlifyAdapter.build({
root: process.cwd(),
appDir: "src/app",
islandsDir: "src/islands",
outDir: "dist",
clientEntry: "/_nix-js/entry-client.js",
lang: "es",
});Run a production server with Bun:
nix-js-kit build
nix-js-kit adapter bun
bun run .nix-js/bun-server.tsThis generates:
.nix-js/bun-index.ts— SSR handler entry..nix-js/bun-server.ts— Bun server that servesdist/static files and renders pages on demand.
The server respects the PORT environment variable (default 3000). Programmatic usage:
import { bunAdapter } from "@deijose/nix-js-kit/adapters/bun";
await bunAdapter.build({
root: process.cwd(),
appDir: "src/app",
islandsDir: "src/islands",
outDir: "dist",
clientEntry: "/_nix-js/entry-client.js",
lang: "es",
});Run a production server with Node (v18+):
nix-js-kit build
nix-js-kit adapter node
node .nix-js/node-server.mjsThis generates a single bundled .nix-js/node-server.mjs that serves dist/ static files and renders pages on demand. The server respects the PORT environment variable (default 3000). Programmatic usage:
import { nodeAdapter } from "@deijose/nix-js-kit/adapters/node";
await nodeAdapter.build({
root: process.cwd(),
appDir: "src/app",
islandsDir: "src/islands",
outDir: "dist",
clientEntry: "/_nix-js/entry-client.js",
lang: "es",
});When you pass islandsDir and generatedEntry, build() walks the islands
directory and writes a client entry that imports every island and registers it
with hydrateIslands. Point your bundler (Vite/Rollup) at the generated file:
await build({
appDir: "./src/app",
outDir: "./dist",
clientEntry: "/_nix-js/entry-client.js",
islandsDir: "./src/islands",
generatedEntry: "./.nix-js/entry-client.ts",
});Given src/islands/LikeButton.ts and src/islands/nav/MobileMenu.ts, the
generated .nix-js/entry-client.ts looks like:
// AUTO-GENERATED by @deijose/nix-js-kit. Do not edit.
import { hydrateIslands } from "@deijose/nix-js-kit/island";
import LikeButton_0 from "../src/islands/LikeButton";
import MobileMenu_1 from "../src/islands/nav/MobileMenu";
hydrateIslands({
"LikeButton": LikeButton_0,
"nav/MobileMenu": MobileMenu_1,
});The build() result also reports the discovered islands:
const result = await build({ /* ... */ });
result.islands; // [{ name: "LikeButton", filePath: "…" }, …]
result.generatedEntry; // absolute path to the generated entryYou can also call the lower-level helpers directly:
import { scanIslands, generateClientEntry } from "@deijose/nix-js-kit";
const islands = await scanIslands("./src/islands");
await generateClientEntry({ islands, outFile: "./.nix-js/entry-client.ts" });my-app/
├── src/
│ └── app/
│ ├── layout.ts # root layout
│ ├── page.ts # home page
│ ├── page.data.ts # home loader
│ ├── page.action.ts # home server actions
│ ├── 404.page.ts # custom 404 page
│ ├── 500.page.ts # custom 500 page
│ ├── blog/
│ │ ├── page.ts
│ │ ├── page.data.ts
│ │ └── page.action.ts
│ └── api/
│ └── posts/
│ └── route.ts # API endpoint
├── nix.config.ts
└── vite.config.ts
MIT © Deiver Vasquez