Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

78 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

NukeJS Banner

NukeJS npm website

A minimal, opinionated full-stack React framework on Node.js that server-renders everything and hydrates only interactive parts.

npm create nuke@latest

Table of Contents

Overview

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

The core idea

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>;
}

Getting Started

Prerequisites

  • Node.js 20+
  • React 19+
  • esbuild (peer dependency)

Installation

npm create nuke@latest

Running the dev server

npm run dev

The server starts on port 3000 by default (auto-increments if in use).


Project Structure

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

Pages & Routing

Basic pages

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)

Page component

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 params

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[].

Catch-all routes

// 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} />;
}

Route specificity

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]].


Layouts

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).

Title templates in layouts

// 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>;
}

Client Components

Add "use client" as the very first line of any component file to make it a client component. NukeJS will:

  1. Bundle that file separately and serve it as /__client-component/<id>.js
  2. Render a <span data-hydrate-id="…"> placeholder in the server HTML
  3. 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>
  );
}

Rules for client components

  • 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)

Passing children to client components

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.

Web Workers

"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.

Workers from installed packages

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.

Overriding the URL

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.

What's still out of reach

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.


State Management

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.

createStore

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.

useStore

"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);

Sharing state across components

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.

setState

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 }));

API reference

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

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.


API Routes

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();
}

Request object

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. Pipe req directly into a multipart parser instead:

import busboy from 'busboy';
const bb = busboy({ headers: req.headers });
req.pipe(bb);

Response object

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

Middleware

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.

URL rewriting

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);
  }
}

CORS & OPTIONS handling

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
    }
  }
}

Maintenance mode

// 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>');
  }
}

Static Files

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}
    </>
  );
}

Deployment behaviour

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

useHtml() — Head Management

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>;
}

Title resolution order

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.

Script injection & position

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.

Inline style injection

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)')

Configuration

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,
};

Link Component & Navigation

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

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

router.replace — navigation without a history entry

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>;
}

router.back — back button

"use client";
import { useRouter } from 'nukejs';

export default function BackButton() {
  const { back } = useRouter();
  return <button onClick={back}>← Go back</button>;
}

router.refresh — re-render after a mutation

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 — reactive current path

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() — URL Params, Query & Headers

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

Where data comes from

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: headers on the client never contains cookie, authorization, proxy-authorization, set-cookie, or x-api-key. These are stripped before embedding in the HTML document so credentials cannot leak into cached or logged pages.

Reading route params and query string

// 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>
  );
}

Catch-all routes

// 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>;
}

Reading headers in a server component

// 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>;
}

Building useI18n on top

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.


cache() — Request-Scoped Data Caching

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.

Why not just use React's cache()?

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.

Scope & behaviour

  • 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), so getUser('42') and getUser('42') hit the same entry, but getUser('42') and getUser('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.


Error Pages

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.

_404.tsx — Page Not Found

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>
  );
}

_500.tsx — Internal Server Error

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>
  );
}

Server errors

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>;
}

Client errors

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 code
  • window.onunhandledrejection — catches unhandled Promise rejections from async functions
// 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.

Behaviour

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

Notes

  • Error pages are excluded from routing/_404 and /_500 are not reachable as URLs.
  • They participate in the root layout.tsx like 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.
  • errorStack is only populated in development (NODE_ENV !== 'production').

Building & Deploying

Node.js server

npm run build       # builds to dist/
node dist/index.mjs # starts the production server

The 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

Vercel

Just import the code from GitHub. NukeJS detects the Vercel environment automatically and builds the right output — no configuration needed.

Cloudflare Workers & Pages

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/output in the Pages dashboard. Static assets are served via CDN automatically through the ASSETS binding.

Cloudflare Workers: Use wrangler deploy as your deploy command. Static assets are inlined into the worker bundle at build time — no separate CDN step required.

Environment variables

Variable Description
ENVIRONMENT=production Disables HMR and file watching
PORT Port for the production server

License

MIT

About

A minimal, opinionated full-stack React framework on Node.js that server-renders everything and hydrates only interactive parts.

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Contributors

Languages