A minimal, opinionated full-stack React framework on Node.js that server-renders everything and hydrates only interactive parts.
npm create nuke@latest
- Overview
- Getting Started
- Project Structure
- Pages & Routing
- Layouts
- Client Components
- Web Workers
- State Management
- API Routes
- Middleware
- Static Files
- useHtml() — Head Management
- Configuration
- Link Component & Navigation
- useRequest() — URL Params, Query & Headers
- cache() — Request-Scoped Data Caching
- Error Pages
- Building & Deploying
NukeJS gives you:
| Feature | Description |
|---|---|
| File-based routing | Pages in app/pages/, API in server/ |
| Server-side rendering | All pages rendered to HTML on the server |
| Partial hydration | Only "use client" components download JS |
| SPA navigation | Client-side page transitions after first load |
| Hot module replacement | Instant page updates during development |
| Zero config | Works out of the box; nuke.config.ts for overrides |
| Deploy anywhere | Node.js, Vercel, or Cloudflare — zero config |
Most pages don't need JavaScript. NukeJS renders your entire React tree to HTML on the server, and only ships JavaScript for components explicitly marked "use client". Everything else stays server-only — no hydration cost, no JS bundle for static content.
// app/pages/index.tsx — Server component (zero JS sent to browser)
export default async function Home() {
const posts = await db.getPosts(); // runs on server only
return (
<main>
<h1>Blog</h1>
{posts.map(p => <PostCard key={p.id} post={p} />)}
<LikeButton postId={posts[0].id} /> {/* ← this one is interactive */}
</main>
);
}// app/components/LikeButton.tsx — Client component (JS downloaded)
"use client";
import { useState } from 'react';
export default function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>{liked ? '❤️' : '🤍'}</button>;
}- Node.js 20+
- React 19+
- esbuild (peer dependency)
npm create nuke@latestnpm run devThe server starts on port 3000 by default (auto-increments if in use).
my-app/
├── app/
│ ├── pages/ # Page components (file-based routing)
│ │ ├── layout.tsx # Root layout (wraps every page)
│ │ ├── index.tsx # → /
│ │ ├── about.tsx # → /about
│ │ └── blog/
│ │ ├── layout.tsx # Blog section layout
│ │ ├── index.tsx # → /blog
│ │ └── [slug].tsx # → /blog/:slug
│ ├── components/ # Shared components (not routed)
│ └── public/ # Static files served at root (e.g. /favicon.ico)
├── server/ # API route handlers
│ ├── users/
│ │ ├── index.ts # → GET/POST /users
│ │ └── [id].ts # → GET/PUT/DELETE /users/:id
│ └── auth.ts # → /auth
├── middleware.ts # (optional) global request middleware
├── nuke.config.ts # (optional) configuration
└── package.json
Each .tsx file in app/pages/ maps to a URL route:
| File | URL |
|---|---|
index.tsx |
/ |
about.tsx |
/about |
blog/index.tsx |
/blog |
blog/[slug].tsx |
/blog/:slug |
docs/[...path].tsx |
/docs/* (catch-all, required) |
users/[[id]].tsx |
/users or /users/42 (optional single segment) |
files/[[...path]].tsx |
/files or /files/* (optional catch-all) |
A page exports a default React component. It may be async (runs on the server).
// app/pages/blog/[slug].tsx
export default async function BlogPost({ slug }: { slug: string }) {
const post = await fetchPost(slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}Route params are passed as props to the component.
Query string parameters are automatically merged into the page component's props alongside route params. If a query param shares a name with a route param, the route param takes precedence.
// app/pages/search.tsx
// URL: /search?q=nuke&page=2
export default function Search({ q, page }: { q: string; page: string }) {
return <h1>Results for "{q}" — page {page}</h1>;
}// app/pages/blog/[slug].tsx
// URL: /blog/hello-world?preview=true
export default function BlogPost({ slug, preview }: { slug: string; preview?: string }) {
return <article data-preview={preview}>{slug}</article>;
}A query param that appears multiple times (e.g. ?tag=a&tag=b) is passed as a string[].
// app/pages/docs/[...path].tsx
export default function Docs({ path }: { path: string[] }) {
// path = ['getting-started', 'installation'] for /docs/getting-started/installation
return <DocViewer segments={path} />;
}When multiple routes could match a URL, the most specific one wins:
/users/profile → users/profile.tsx (static, wins)
/users/42 → users/[id].tsx (dynamic)
/users → users/[[id]].tsx (optional single, matches with no id)
/users/a/b/c → users/[...rest].tsx (catch-all)
Specificity order, highest to lowest: static → [param] → [[param]] → [...catchAll] → [[...optionalCatchAll]].
Place a layout.tsx alongside your pages to wrap a group of routes.
// app/pages/layout.tsx — Wraps every page
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<div>
<Nav />
<main>{children}</main>
<Footer />
</div>
);
}Layouts nest automatically. A page at blog/[slug].tsx gets wrapped by both layout.tsx (root) and blog/layout.tsx (blog section).
// app/pages/layout.tsx
import { useHtml } from 'nukejs';
export default function RootLayout({ children }: { children: React.ReactNode }) {
useHtml({ title: (prev) => `${prev} | Acme Corp` });
return <>{children}</>;
}
// app/pages/about.tsx
export default function About() {
useHtml({ title: 'About Us' });
// Final title: "About Us | Acme Corp"
return <h1>About</h1>;
}Add "use client" as the very first line of any component file to make it a client component. NukeJS will:
- Bundle that file separately and serve it as
/__client-component/<id>.js - Render a
<span data-hydrate-id="…">placeholder in the server HTML - Hydrate the placeholder with React in the browser
"use client";
import { useState, useEffect } from 'react';
export default function Counter({ initial = 0 }: { initial?: number }) {
const [count, setCount] = useState(initial);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}- The
"use client"directive must be the first non-comment line - The component must have a named default export (NukeJS uses the function name to match props during hydration)
- Props must be JSON-serializable (no functions, no class instances)
- React elements passed as props are supported (serialized and reconstructed)
Children and other React elements can be passed as props — NukeJS serializes them at render time:
// Server component
<Modal>
<p>This content is from the server</p>
</Modal>
// Modal is a "use client" component; its children are serialized as
// { __re: 'html', tag: 'p', props: { children: 'This content...' } }
// and reconstructed in the browser before mounting."use client" components can create Web Workers using the standard modern bundler pattern — no manual copying of worker files into app/public/, and no extra config:
"use client";
function makeParserWorker() {
return new Worker(new URL("./parser.worker.ts", import.meta.url), {
type: "module",
});
}Any new URL("./relative/path", import.meta.url) reachable from a client component is discovered automatically while NukeJS bundles your app. The referenced file is bundled with esbuild as its own entry point, written out with a content hash in its filename, and the new URL(...) call is rewritten in place to point at the emitted file — in both nuke dev and nuke build, under the same /__worker/<name>-<hash>.js URL, so app code never has to branch on mode.
This also works when the worker lives inside a package you installed, either because the package references its own co-located worker internally:
// resolved correctly even though voiceClient.js ships inside node_modules
new URL("./workers/stt-worker.js", import.meta.url)or because your own code points at a worker a package exports by subpath:
new Worker(
new URL("monaco-editor/esm/vs/language/json/json.worker.js", import.meta.url),
{ type: "module" }
)Bare specifiers are resolved through Node's own module resolution (honoring that package's exports/main), so the correct installed copy is found the same way a real import of that specifier would find it.
Nothing here gets in the way of supplying your own worker URL at runtime — that's just a plain string, which is never something this rewrite touches:
function makeParserWorker(options: { workerUrls?: { parser?: string } } = {}) {
if (options.workerUrls?.parser) {
return new Worker(options.workerUrls.parser, { type: "module" });
}
return new Worker(new URL("./parser.worker.ts", import.meta.url), {
type: "module",
});
}If options.workerUrls.parser is set, it's always used. Otherwise the bundled worker is resolved automatically.
Only string literals are resolved — a dynamic path (new URL(someVariable, import.meta.url)) can't be, since there's nothing to inspect until the code actually runs. And a worker a package never spells out as a specifier anywhere — no relative path, no subpath export, located via some entirely runtime-only scheme — can't be resolved by any static, build-time plugin, for this or any other bundler.
NukeJS ships a lightweight built-in store for sharing state across client components. Because each "use client" component is hydrated into its own independent React root, React Context cannot cross component boundaries — the store solves this.
All store state lives in window.__nukeStores, so it is shared across every bundle on the page regardless of how many times a store module is evaluated.
import { createStore } from 'nukejs';
const counterStore = createStore('counter', { count: 0 });The first argument is a unique name used to key the store in the global registry. The second is the initial state. If two bundles call createStore with the same name, the first one wins and subsequent calls reuse the existing entry.
"use client"
import { useStore } from 'nukejs';
import { counterStore } from '../stores/counter';
export default function Counter() {
const { count } = useStore(counterStore);
return (
<div>
<p>{count}</p>
<button onClick={() => counterStore.setState(s => ({ count: s.count + 1 }))}>
Increment
</button>
</div>
);
}Pass an optional selector to avoid re-renders when unrelated parts of state change:
// Only re-renders when `count` changes, not on any other state update
const count = useStore(counterStore, s => s.count);Stores shine when two completely separate client components need to stay in sync. Define the store in its own file (no "use client" needed) and import it from both:
// app/stores/cart.ts
import { createStore } from 'nukejs';
export type CartItem = { id: string; name: string; price: number };
export const cartStore = createStore('cart', {
items: [] as CartItem[],
total: 0,
});// app/components/AddToCartButton.tsx
"use client"
import { cartStore, type CartItem } from '../stores/cart';
export default function AddToCartButton({ item }: { item: CartItem }) {
return (
<button onClick={() =>
cartStore.setState(s => ({
items: [...s.items, item],
total: s.total + item.price,
}))
}>
Add to cart
</button>
);
}// app/components/CartIcon.tsx
"use client"
import { useStore } from 'nukejs';
import { cartStore } from '../stores/cart';
export default function CartIcon() {
const { items, total } = useStore(cartStore);
return (
<span>
🛒 {items.length} items — ${total.toFixed(2)}
</span>
);
}// app/pages/shop.tsx — server component, no JS cost
import AddToCartButton from '../components/AddToCartButton';
import CartIcon from '../components/CartIcon';
export default function ShopPage() {
const item = { id: '1', name: 'Widget', price: 9.99 };
return (
<div>
<CartIcon />
<AddToCartButton item={item} />
</div>
);
}CartIcon updates instantly when the button is clicked — they are separate React roots with separate bundles, but they write and read through the same 'cart' entry in window.__nukeStores.
Accepts a full replacement value or an updater function:
// Replace
cartStore.setState({ items: [], total: 0 });
// Updater — receives current state, returns next state
cartStore.setState(s => ({ ...s, total: s.total + 5 }));| Description | |
|---|---|
createStore(name, initialState) |
Creates or retrieves a named store |
useStore(store) |
Subscribes to the full state |
useStore(store, selector) |
Subscribes to a derived slice (re-renders only when slice changes) |
store.getState() |
Returns the current state snapshot |
store.setState(updater) |
Updates state and notifies all subscribers |
store.subscribe(listener) |
Registers a listener; returns an unsubscribe function |
createPersistedStore is a drop-in replacement for createStore that survives full page refreshes by mirroring state into localStorage (or sessionStorage). A plain createStore lives only in window.__nukeStores and is wiped on every hard reload — fine for SPA navigations, not for data you want to keep around.
import { createPersistedStore } from 'nukejs';
// Persists to localStorage under the key "nuke-store:cart"
export const cartStore = createPersistedStore('cart', {
items: [] as CartItem[],
total: 0,
});
// Cleared when the tab closes, kept across refreshes within the session
export const draftStore = createPersistedStore('draft', { text: '' }, {
storage: 'session',
});
// Custom storage key
export const settingsStore = createPersistedStore('settings', { theme: 'light' }, {
key: 'my-app:settings',
});| Option | Type | Default | Description |
|---|---|---|---|
storage |
'local' | 'session' |
'local' |
Storage backend — localStorage or sessionStorage |
key |
string |
nuke-store:{name} |
Override the key used in storage |
On first mount, the persisted value is read from storage and applied via setState immediately after the store is created. On every subsequent state change the new value is written back. The initialState you pass in is still used as the SSR snapshot, so there are no hydration mismatches — components simply re-render with the persisted value after mount.
// app/components/ThemeToggle.tsx
"use client";
import { useStore } from 'nukejs';
import { settingsStore } from '../stores/settings';
export default function ThemeToggle() {
const { theme } = useStore(settingsStore);
return (
<button onClick={() =>
settingsStore.setState(s => ({ ...s, theme: s.theme === 'light' ? 'dark' : 'light' }))
}>
Switch to {theme === 'light' ? 'dark' : 'light'} mode
</button>
);
}The selected theme persists across hard reloads without any extra wiring.
Export named HTTP method handlers from .ts files in your server/ directory.
// server/users/index.ts
import type { ApiRequest, ApiResponse } from 'nukejs';
export async function GET(req: ApiRequest, res: ApiResponse) {
const users = await db.getUsers();
res.json(users);
}
export async function POST(req: ApiRequest, res: ApiResponse) {
const body = await req.json();
const user = await db.createUser(body);
res.json(user, 201);
}// server/users/[id].ts
export async function GET(req: ApiRequest, res: ApiResponse) {
const { id } = req.params as { id: string };
const user = await db.getUser(id);
if (!user) { res.json({ error: 'Not found' }, 404); return; }
res.json(user);
}
export async function DELETE(req: ApiRequest, res: ApiResponse) {
await db.deleteUser(req.params!.id as string);
res.status(204).end();
}| Property / Method | Type | Description |
|---|---|---|
req.json<T>() |
Promise<T> |
Parse the request body as JSON (10 MB limit, prototype-pollution guard) |
req.text() |
Promise<string> |
Read the request body as a UTF-8 string (10 MB limit) |
req.buffer() |
Promise<Buffer> |
Read the request body as a raw Buffer — use this for binary or multipart data |
req.params |
Record<string, string | string[]> |
Dynamic route segments |
req.query |
Record<string, string> |
URL search params |
req.method |
string |
HTTP method |
req.headers |
IncomingHttpHeaders |
Request headers |
Multipart / file uploads: body helpers do not parse
multipart/form-data. Pipereqdirectly into a multipart parser instead:import busboy from 'busboy'; const bb = busboy({ headers: req.headers }); req.pipe(bb);
| Method | Description |
|---|---|
res.json(data, status?) |
Send a JSON response (default status 200) |
res.status(code) |
Set status code and return res for chaining |
res.setHeader(name, value) |
Set a response header |
res.end(body?) |
Send raw response |
Create middleware.ts in your project root to intercept every request before routing:
// middleware.ts
import type { IncomingMessage, ServerResponse } from 'http';
export default async function middleware(
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
// Logging
console.log(`${req.method} ${req.url}`);
// Auth guard
if (req.url?.startsWith('/admin') && !isAuthenticated(req)) {
res.statusCode = 401;
res.end('Unauthorized');
return; // End response to halt further processing
}
// Header injection (let request continue without ending it)
res.setHeader('X-Powered-By', 'nukejs');
}If res.end() (or res.json()) is called, NukeJS stops processing and does not handle the request through routing. If middleware returns without ending the response, the request continues to API routes or SSR.
Middleware can transparently rewrite the incoming URL by mutating req.url. The rewritten URL is used for routing — the browser's address bar is unchanged:
// middleware.ts
import type { IncomingMessage, ServerResponse } from 'http';
export default async function middleware(req: IncomingMessage, res: ServerResponse) {
// Redirect legacy paths to their new equivalents without a browser redirect
if (req.url === '/old-page') {
req.url = '/new-page'; // routed to app/pages/new-page.tsx, URL stays /old-page
}
// Normalize trailing slashes
if (req.url && req.url !== '/' && req.url.endsWith('/')) {
req.url = req.url.slice(0, -1);
}
}A common pattern for API routes is to set CORS headers and short-circuit OPTIONS preflight requests in middleware:
// middleware.ts
import type { IncomingMessage, ServerResponse } from 'http';
export default async function middleware(req: IncomingMessage, res: ServerResponse) {
if (req.url?.startsWith('/api/')) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return; // short-circuits routing
}
}
}// middleware.ts
import type { IncomingMessage, ServerResponse } from 'http';
export default async function middleware(req: IncomingMessage, res: ServerResponse) {
// Bypass maintenance for internal NukeJS routes (/__hmr, /__client-component/*)
if (process.env.MAINTENANCE_MODE === 'true' && !req.url?.startsWith('/__')) {
res.statusCode = 503;
res.setHeader('Content-Type', 'text/html');
res.end('<h1>503 — Down for maintenance</h1><p>Back shortly.</p>');
}
}Place any file in app/public/ and it will be served directly at its path relative to that directory — no route file needed.
app/public/
├── favicon.ico → GET /favicon.ico
├── robots.txt → GET /robots.txt
├── logo.png → GET /logo.png
└── fonts/
└── inter.woff2 → GET /fonts/inter.woff2
Every file type is served with the correct Content-Type automatically (images, fonts, CSS, video, audio, JSON, WASM, etc.).
Reference public files directly in your components:
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<link rel="icon" href="/favicon.ico" />
<img src="/logo.png" alt="Logo" />
{children}
</>
);
}| Environment | How public files are served |
|---|---|
nuke dev |
Served by the built-in middleware before any API or SSR routing |
nuke build (Node) |
Copied to dist/static/ and served by the production HTTP server |
nuke build (Vercel) |
Copied to .vercel/output/static/ — served by Vercel's CDN, no function invocation |
nuke build (Cloudflare) |
Copied to .cloudflare/output/static/ — served by Cloudflare's CDN, no Worker invocation |
The useHtml() hook works in both server components and client components to control the document <head>, <html> attributes, <body> attributes, and scripts injected at the end of <body>.
import { useHtml } from 'nukejs';
export default function Page() {
useHtml({
title: 'My Page',
meta: [
{ name: 'description', content: 'Page description' },
{ property: 'og:title', content: 'My Page' },
],
link: [
{ rel: 'canonical', href: 'https://example.com/page' },
{ rel: 'stylesheet', href: '/styles.css' },
],
htmlAttrs: { lang: 'en', class: 'dark' },
bodyAttrs: { class: 'page-home' },
});
return <main>...</main>;
}When both a layout and a page call useHtml({ title }), they are resolved in this order:
Layout: useHtml({ title: (prev) => `${prev} | Site` })
Page: useHtml({ title: 'Home' })
Result: "Home | Site"
The page title always serves as the base value; layout functions wrap it outward.
The script option accepts an array of script tags. Each entry supports the standard attributes (src, type, async, defer, content for inline scripts, etc.) plus a position field:
position |
Where it's injected |
|---|---|
'head' (default) |
Inside <head>, in the managed <!--n-head--> block |
'body' |
End of <body>, just before </body>, in the <!--n-body-scripts--> block |
Use position: 'body' for third-party analytics and tracking scripts (Google Analytics, Hotjar, Intercom, etc.) that should load after page content is in the DOM and must not block rendering.
// app/pages/layout.tsx — Google Analytics on every page
import { useHtml } from 'nukejs';
export default function RootLayout({ children }: { children: React.ReactNode }) {
useHtml({
script: [
// Load the gtag library — async so it doesn't block rendering
{
src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX',
async: true,
position: 'body',
},
// Inline initialisation — must follow the loader above
{
content: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
`,
position: 'body',
},
],
});
return <>{children}</>;
}Use position: 'head' (the default) for scripts that must run before first paint, such as theme detection to avoid flash-of-unstyled-content:
useHtml({
script: [
{
content: `
const theme = localStorage.getItem('theme') ?? 'light';
document.documentElement.classList.add(theme);
`,
// position defaults to 'head' — runs before the page renders
},
],
});Both head and body scripts are re-executed on every HMR update and SPA navigation so they always reflect the current page state.
The style option injects <style> blocks into <head>. This is useful for critical CSS that must be present before first paint, or for component-scoped styles that vary per page:
// app/pages/dashboard.tsx
import { useHtml } from 'nukejs';
export default function Dashboard() {
useHtml({
style: [
{ content: `.chart { height: 400px; }` },
{ content: `.sidebar { width: 260px; }`, media: '(min-width: 768px)' },
],
});
return <main>...</main>;
}Each StyleTag entry supports:
| Field | Type | Description |
|---|---|---|
content |
string |
Raw CSS injected inside a <style> block |
media |
string |
Optional media attribute (e.g. 'print', '(prefers-color-scheme: dark)') |
Create nuke.config.ts in your project root:
// nuke.config.ts
export default {
// Directory containing API route files (default: './server')
serverDir: './server',
// Port for the dev server (default: 3000, auto-increments if in use)
port: 3000,
// Logging verbosity
// false — silent (default)
// 'error' — errors only
// 'info' — startup messages + errors
// true — verbose (all debug output)
debug: false,
};Use the built-in <Link> component for client-side navigation (no full page reload):
import { Link } from 'nukejs';
export default function Nav() {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href="/blog">Blog</Link>
</nav>
);
}<Link> accepts a className prop for styling:
<Link href="/pricing" className="nav-link nav-link--active">
Pricing
</Link>| Prop | Type | Description |
|---|---|---|
href |
string |
Destination URL |
children |
React.ReactNode |
Link content |
className |
string (optional) |
CSS class(es) applied to the underlying <a> element |
useRouter() gives client components programmatic control over navigation. It can only be used inside "use client" components.
"use client";
import { useRouter } from 'nukejs';
export default function SearchForm() {
const router = useRouter();
return (
<button onClick={() => router.push('/results?q=nuke')}>
Search
</button>
);
}The hook returns an object with the following properties and methods:
| Type | Description | |
|---|---|---|
router.path |
string |
The current pathname — reactive, updates on every SPA navigation |
router.push(url) |
(url: string) => void |
Navigate to a new URL, adding an entry to the browser history |
router.replace(url) |
(url: string) => void |
Navigate without adding a history entry (replaces the current one) |
router.back() |
() => void |
Go back one entry in the browser history (same as window.history.back()) |
router.refresh() |
() => void |
Re-trigger the current route without a URL change — useful after a mutation |
Use replace when you want to update the URL (e.g. after a form submit or login redirect) without letting the user go "back" to the previous state:
"use client";
import { useRouter } from 'nukejs';
export default function LoginForm() {
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
await login();
router.replace('/dashboard'); // can't go back to /login
}
return <form onSubmit={handleSubmit}>...</form>;
}"use client";
import { useRouter } from 'nukejs';
export default function BackButton() {
const { back } = useRouter();
return <button onClick={back}>← Go back</button>;
}refresh replays the current route without changing the URL. Use it after a server mutation to re-fetch and display the updated data:
"use client";
import { useRouter } from 'nukejs';
export default function DeleteButton({ postId }: { postId: string }) {
const { refresh } = useRouter();
async function handleDelete() {
await fetch(`/api/posts/${postId}`, { method: 'DELETE' });
refresh(); // re-renders the page so the deleted post disappears
}
return <button onClick={handleDelete}>Delete post</button>;
}router.path stays in sync with the current pathname across SPA navigations — no window.location polling required:
"use client";
import { useRouter } from 'nukejs';
export default function NavLink({ href, label }: { href: string; label: string }) {
const { path, push } = useRouter();
const isActive = path === href;
return (
<a
href={href}
onClick={(e) => { e.preventDefault(); push(href); }}
style={{ fontWeight: isActive ? 'bold' : 'normal' }}
>
{label}
</a>
);
}useRequest() is a universal hook that exposes the current request's URL parameters, query string, and headers to any component — server or client, dev or production.
import { useRequest } from 'nukejs';
const { params, query, headers, pathname, url } = useRequest();| Field | Type | Description |
|---|---|---|
url |
string |
Full URL with query string, e.g. /blog/hello?lang=en |
pathname |
string |
Path only, e.g. /blog/hello |
params |
Record<string, string | string[]> |
Dynamic route segments |
query |
Record<string, string | string[]> |
Query-string params (multi-value keys become arrays) |
headers |
Record<string, string> |
Request headers |
| Environment | Source |
|---|---|
| Server (SSR) | Live IncomingMessage — all headers including cookie |
| Client (browser) | __n_data blob embedded in the page + window.location (reactive) |
On the client the hook is reactive: it re-reads on every SPA navigation so query, pathname, and params stay current without a page reload.
Security:
headerson the client never containscookie,authorization,proxy-authorization,set-cookie, orx-api-key. These are stripped before embedding in the HTML document so credentials cannot leak into cached or logged pages.
// app/pages/blog/[slug].tsx
// URL: /blog/hello-world?tab=comments
import { useRequest } from 'nukejs';
export default function BlogPost() {
const { params, query } = useRequest();
const slug = params.slug as string;
const tab = (query.tab as string) ?? 'overview';
return (
<article>
<h1>{slug}</h1>
<p>Active tab: {tab}</p>
</article>
);
}// app/pages/docs/[...path].tsx
// URL: /docs/api/hooks → path = ['api', 'hooks']
import { useRequest } from 'nukejs';
export default function Docs() {
const { params } = useRequest();
const segments = params.path as string[];
return <nav>{segments.join(' › ')}</nav>;
}// app/pages/dashboard.tsx
import { useRequest } from 'nukejs';
export default async function Dashboard() {
const { headers } = useRequest();
// Forward the session cookie to an internal API call
const data = await fetch('http://localhost:3000/api/me', {
headers: { cookie: headers['cookie'] ?? '' },
}).then(r => r.json());
return <main>{data.name}</main>;
}useRequest is designed as a primitive for higher-level hooks. Here is a complete useI18n implementation that works in both server and client components:
// app/hooks/useI18n.ts
import { useRequest } from 'nukejs';
const translations = {
en: { welcome: 'Welcome', signIn: 'Sign in' },
fr: { welcome: 'Bienvenue', signIn: 'Se connecter' },
de: { welcome: 'Willkommen', signIn: 'Anmelden' },
} as const;
type Locale = keyof typeof translations;
function detectLocale(
query: Record<string, string | string[]>,
acceptLanguage = '',
): Locale {
// ?lang=fr in the URL takes priority over the browser header
const fromQuery = query.lang as string | undefined;
if (fromQuery && fromQuery in translations) return fromQuery as Locale;
const fromHeader = acceptLanguage
.split(',')[0]?.split('-')[0]?.trim().toLowerCase();
if (fromHeader && fromHeader in translations) return fromHeader as Locale;
return 'en';
}
export function useI18n() {
const { query, headers } = useRequest();
const locale = detectLocale(query, headers['accept-language']);
return { t: translations[locale], locale };
}// app/pages/index.tsx
import { useI18n } from '../hooks/useI18n';
export default function Home() {
const { t } = useI18n();
return <h1>{t.welcome}</h1>;
}Changing ?lang=fr in the URL re-renders client components automatically.
Server components render independently, so if two components in the same page tree both need the same data, NukeJS has no way to know they're asking for the same thing — each one triggers its own database call, fetch, or computation, even though they're part of the same request:
// app/components/Nav.tsx
export default async function Nav() {
const user = await db.getUser(useRequest().params.id); // DB call #1
return <nav>{user.name}</nav>;
}
// app/pages/dashboard.tsx
export default async function Dashboard() {
const user = await db.getUser(useRequest().params.id); // DB call #2 — same data
return <main>{user.email}</main>;
}cache() wraps an async function so repeated calls with the same arguments, within the same request, share a single call:
// lib/data.ts
import { cache } from 'nukejs';
export const getUser = cache(async (id: string) => {
return db.getUser(id); // only runs once per request, however many
// components call getUser(id) with that id
});// app/components/Nav.tsx
import { getUser } from '../../lib/data';
export default async function Nav() {
const user = await getUser(useRequest().params.id as string);
return <nav>{user.name}</nav>;
}
// app/pages/dashboard.tsx
import { getUser } from '../../lib/data';
export default async function Dashboard() {
const user = await getUser(useRequest().params.id as string); // same call → no extra DB hit
return <main>{user.email}</main>;
}Whichever component calls getUser('42') first triggers the real call; every other call with '42' in that same request — even ones that fire concurrently, before the first has resolved — awaits that same in-flight promise instead of starting a new one.
React ships its own cache(), but it relies on the React Server Components renderer to signal "this is a new request, reset the memoization table." NukeJS doesn't run the RSC protocol — it renders each request to a single HTML string (see Pages & Routing) — so nothing would ever tell React's cache() to reset, risking one request's cached data leaking into another's response. NukeJS's cache() has the same signature and mental model as React's, but is tied to NukeJS's own per-request lifecycle instead, so it resets correctly on every request.
- One request, one cache. The cache is created fresh before rendering starts and discarded once the response is sent. There's no cross-request caching and nothing to invalidate — the next request simply starts with an empty cache.
- Keyed by function + arguments. Two different
cache()-wrapped functions never share entries, even if called with identical arguments. Arguments are compared by value (via JSON serialization), sogetUser('42')andgetUser('42')hit the same entry, butgetUser('42')andgetUser('43')don't. - Concurrent calls are deduped too. If two components call the same cached function before the first call has settled, both await the same pending promise — only one underlying call is made.
- Failed calls aren't stuck. If a cached call rejects, that entry is removed immediately, so a later call with the same arguments in the same request retries instead of replaying the same error.
- Non-serializable arguments skip caching. Arguments that can't be safely compared by value (functions, Symbols, cyclic objects) just call straight through, unmemoized, rather than being cached under an incorrect key.
- Safe to call anywhere. Outside of an active request — a script, a test, client-side code — a
cache()-wrapped function simply calls straight through with no memoization. It's always safe to import and call.
cache() is a pure optimization: removing it (or calling the wrapped function directly) never changes what your app returns, only how many times the underlying work runs.
NukeJS supports custom error pages for 404 Not Found and 500 Internal Server Error. Place them directly in app/pages/ — they are standard server components and support everything regular pages do: layouts, useHtml(), client components, and HMR in dev.
Rendered when no route matches the requested URL.
// app/pages/_404.tsx
import { useHtml } from 'nukejs';
import { Link } from 'nukejs';
export default function NotFound() {
useHtml({ title: 'Page Not Found' });
return (
<main>
<h1>404 — Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<Link href="/">Go home</Link>
</main>
);
}Rendered when a page handler throws an unhandled error. The error is passed as optional props so you can display details in development.
// app/pages/_500.tsx
import { useHtml } from 'nukejs';
import { Link } from 'nukejs';
interface ErrorProps {
errorMessage?: string; // human-readable error description
errorStatus?: string; // HTTP status code if present on the thrown error
errorStack?: string; // stack trace — only populated in development
}
export default function ServerError({ errorMessage, errorStack }: ErrorProps) {
useHtml({ title: 'Something went wrong' });
return (
<main>
<h1>500 — Server Error</h1>
<p>Something went wrong on our end. Please try again.</p>
{errorMessage && <p><strong>{errorMessage}</strong></p>}
{errorStack && <pre>{errorStack}</pre>}
<Link href="/">Go home</Link>
</main>
);
}Any unhandled throw inside a server page component (including async data fetching) routes to _500.tsx. The error message and stack trace are forwarded as props automatically.
// app/pages/dashboard.tsx
export default async function Dashboard() {
const data = await fetchData(); // throws → _500.tsx is rendered
return <main>{data.name}</main>;
}You can also attach a status property to a thrown error to control the HTTP status code sent with the response:
export default async function Post({ id }: { id: string }) {
const post = await db.getPost(id);
if (!post) {
const err = new Error('Post not found');
(err as any).status = 404;
throw err; // _500.tsx receives errorMessage="Post not found", errorStatus="404"
}
return <article>{post.title}</article>;
}Unhandled errors in client components and async code are automatically caught and routed to _500.tsx via an in-place SPA navigation — no full page reload. Three mechanisms cover all cases:
- React Error Boundary — wraps every hydrated
"use client"component; catches render and lifecycle errors window.onerror— catches synchronous throws in event handlers and other non-React codewindow.onunhandledrejection— catches unhandledPromiserejections fromasyncfunctions
// app/components/FaultyButton.tsx
"use client";
export default function FaultyButton() {
const handleClick = () => {
throw new Error('Something broke!'); // caught by window.onerror → _500.tsx
};
return <button onClick={handleClick}>Click me</button>;
}// app/components/FaultyFetch.tsx
"use client";
import { useEffect } from 'react';
export default function FaultyFetch() {
useEffect(() => {
// Unhandled rejection caught by window.onunhandledrejection → _500.tsx
fetch('/api/broken').then(res => {
if (!res.ok) throw new Error(`API error ${res.status}`);
});
}, []);
return <div>Loading...</div>;
}The _500.tsx page receives errorMessage and errorStack props from client errors just like server errors, so a single error page handles both origins consistently.
| Scenario | Without _500.tsx |
With _500.tsx |
|---|---|---|
| Server page throws | Plain-text Internal Server Error (500) |
_500.tsx rendered with error props |
| Client component render error | React crashes the component subtree | _500.tsx rendered in-place, no reload |
| Unhandled event handler throw | Browser console error only | _500.tsx rendered in-place, no reload |
| Unhandled promise rejection | Browser console error only | _500.tsx rendered in-place, no reload |
| Unknown URL | Plain-text Page not found (404) |
_404.tsx rendered with 404 status |
<Link> to unknown URL |
Full page reload | In-place SPA navigation, no reload |
HMR save of _404.tsx / _500.tsx |
— | Current page re-fetches immediately |
- Error pages are excluded from routing —
/_404and/_500are not reachable as URLs. - They participate in the root
layout.tsxlike any other page. - Both are fully bundled into the production output (Node.js and Vercel) — no runtime file-system access required.
- The correct HTTP status code (404 or 500) is always sent in the response.
errorStackis only populated in development (NODE_ENV !== 'production').
npm run build # builds to dist/
node dist/index.mjs # starts the production serverThe build output:
dist/
├── api/ # Bundled API route handlers (.mjs)
├── pages/ # Bundled page handlers (.mjs)
├── static/
│ ├── __n.js # NukeJS client runtime (React + NukeJS bundled together)
│ ├── __client-component/ # Bundled "use client" component files
│ ├── __worker/ # Auto-discovered Web Worker bundles (see Web Workers)
│ └── <app/public files> # Copied from app/public/ at build time
├── manifest.json # Route dispatch table
└── index.mjs # HTTP server entry point
Just import the code from GitHub. NukeJS detects the Vercel environment automatically and builds the right output — no configuration needed.
Just import the code from GitHub. NukeJS detects the Cloudflare environment automatically and builds the right output — no configuration needed.
The build output goes to .cloudflare/output/:
.cloudflare/output/
├── _worker.mjs # Single ESM Cloudflare Worker (all routes bundled)
└── static/
├── __n.js # NukeJS client runtime
├── __client-component/ # Bundled "use client" component files
├── __worker/ # Auto-discovered Web Worker bundles (see Web Workers) — unrelated to _worker.mjs above, which is Cloudflare's own deployment unit
└── <app/public files> # Copied from app/public/ at build time
Static files in static/ are served directly by Cloudflare's CDN — the Worker is only invoked for pages and API routes.
Cloudflare Pages (recommended): Set your build output directory to
.cloudflare/outputin the Pages dashboard. Static assets are served via CDN automatically through theASSETSbinding.
Cloudflare Workers: Use
wrangler deployas your deploy command. Static assets are inlined into the worker bundle at build time — no separate CDN step required.
| Variable | Description |
|---|---|
ENVIRONMENT=production |
Disables HMR and file watching |
PORT |
Port for the production server |
MIT
