A Next.js-compatible web framework for Deno with a zero-npm runtime — the familiar App Router API, ~7× smaller output, and a dependency tree you can actually audit. One unified stack, no Vercel lock-in.
- Docs: denext.dev
- Package: jsr.io/@denext/denext
- Source: github.com/Brainwires/denext
- License: MIT
You already know the API — app/, page.tsx, layout.tsx, "use client",
Server Actions, <Link>, next/image, middleware. denext reimplements that
Next.js core — App Router, streaming SSR, hydration, Suspense — as native
Deno/TypeScript. What's different is underneath: it ships its own tiny
React-equivalent (JSX runtime, hooks, context, a fiber reconciler) instead of
React + ReactDOM + a framework runtime, so there's nothing to npm install
and zero npm in what you ship (CI-enforced). The only third-party runtime
code is a handful of audited @std modules and denext's own first-party JSR
codec (@denext/photon for images) plus Deno's built-in node:sqlite for the
durable cache; the optional image-optimization and next/og routes load a wasm
codec you opt into.
And it's not just Next-shaped apps. A first-class SPA mode
(mode: "spa") hosts any client-only React app — React but not Next — on
the same tiny runtime: bring your own router (TanStack Router, React Router, …)
and data layer, and denext bundles it, swaps in its own React so the browser
downloads ~4.5× less JavaScript than React + ReactDOM
(reproducible bench), and packages it as a single-binary
desktop app via deno desktop. Because denext is React at the reconciler
level, real Vite apps come along unchanged: a 200k-LOC React 19 SPA —
TanStack Router, Effect, Base UI, Lexical, a WebAssembly terminal, Web Workers,
in a pnpm-workspace monorepo — bundles end-to-end on denext's single
React (one reconciler, not two), with Vite ?url/?worker asset imports and
pnpm catalog:/workspace: resolution handled for you. Your existing app; a
fraction of the bytes; a native binary. See SPA mode and
examples/spa.
And it does things stock React can't. Because denext is React at the
reconciler level, it ships two capabilities the React/Next architecture can't
offer without a rewrite. Qwik-style resumability:
export const resumable = true and a page resumes from serialized server state
instead of replaying your component tree on load — you still write ordinary
useState/onClick, with qrl() lazy handlers and useSignal/useStore
signals rounding out the model. Astro-style islands: per-component lazy
hydration with full 6/6 directive parity
(client:load | idle | visible | interaction | media | only) where each island
stays inert server HTML until its strategy fires — real IntersectionObserver /
requestIdleCallback / matchMedia — so an interaction island ships zero JS
until you touch it. As far as we can find, denext is the only framework
delivering Qwik-style resumability on React's own API (Qwik isn't React; Next,
Remix, Astro-React and friends all hydrate). See
Resumability and
Rendering strategies.
// app/page.tsx
import { useState } from "denext";
export const metadata = { title: "Home" };
export default function Home() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>Clicked {n} times</button>;
}deno run -A cli.ts dev examples/hello # → http://localhost:3000
Fair question — Deno can already run genuine Next.js through its npm compat. The reason to reach for denext is the one thing that setup can't give you: a zero-npm dependency tree. Real-Next-on-Deno still drags the full npm graph; denext's own-React reimplementation is the only reason the "nothing from npm" claim holds. That's the wedge, and it buys three concrete things:
- A supply chain you can audit. Zero runtime npm dependencies, enforced in
CI — so the "transitive dependency" advisories that fill
npm auditon a typical React/Next project have nothing to land on, and an SBOM for a denext app is essentially empty. (A positive architecture story — fewer moving parts — not a knock on anyone else.) - ~7× smaller output (measured below), plus a genuinely small single
binary through
deno compile/deno desktop. - One unified stack on native Deno — no bundler config, no
node_modules, no unstable flags to serve, no Vercel lock-in.
Compatibility is the on-ramp, not the whole pitch. It's what makes trying
denext cheap: your Next.js knowledge transfers directly, and an existing App
Router app converts with denext migrate. The reason to stay is the
auditable, tiny, dependency-free output.
denext ships its own small React-equivalent instead of React + ReactDOM + a
framework runtime, so the JavaScript a browser downloads is close to an order
of magnitude smaller (≈7×) than a comparable Next.js app. Measured on the
example app (examples/hello, production build, gzipped):
| What a browser downloads | denext | React + ReactDOM alone | Next.js 16 (First Load JS) |
|---|---|---|---|
| First page load | ~20 KB | ~60 KB | ~137 KB |
| Client runtime baseline | ~19 KB (shared, cached once) | ~60 KB | ~137 KB (shared) |
| Each navigation after the first | ~0.6–1.1 KB (route delta only) | — | route chunk (shared cached) |
The client runtime is bundled into one shared chunk every route references,
so it's downloaded once and cached — a client-side navigation then transfers
only the new route's own code (~0.6 KB gzip on the example), not another copy of
the runtime. No legacy weight by default, either: denext is
function-components-first, and the Pages Router ships as an optional plugin
(@denext/pages-router), so none of it is in the core bundle unless you opt in.
(Class components are supported for running real npm React libraries via the
next-compat build, opt-in through
classComponents and dead-code-eliminated there when unused.)
And a page with no interactivity at all — no hooks, no event handlers, no
dynamic() island — ships zero JavaScript. denext detects static routes at
build time (scanning the route's whole import graph) and skips their client
bundle and hydration script entirely; a <Link> on such a page still works as a
plain anchor. Content and marketing pages are pure HTML.
These are framework-baseline numbers (your own components add on top of both). The Next.js column is a like-for-like build of the same
examples/helloroutes (home counter + lazy island, static about, dynamic blog) with Next.js 16.3 + React 19.2 on Node 24, gzipped — its shared First Load JS is ~137 KB, dominated by react-dom plus the Next.js runtime and shared/turbopack chunks. Older Next (14/15) lands lower. Theexamples/hellobundle budget is enforced by a regression test, so denext's side can't silently regress.
The gap holds on a real, library-heavy app (the same npm libraries compiled
on both sides, gzipped): a recharts dashboard, a react-hook-form route and a
Radix dialog each ship roughly half or less of their Next.js equivalent, and
denext isn't trading size for speed — hydration and SSR throughput run on par or
faster. The current measured numbers live in bench/REPORT.md
(regenerated with every bench run, so this paragraph never goes stale).
Full comparison: bench/REPORT.md plots denext against
Next.js / React across bytes over the wire, SSR throughput,
time-to-interactive, and the real library-heavy app. Every number is
reproducible via bench/run.ts; the raw results and methodology live in
bench/REPORT.md. (Single-machine benchmark — trust the
ratios, not the absolute milliseconds.)
Zero-npm and small bundles are one wedge. The other is capability: because denext owns the whole stack — the cache, the Flight boundary, the reconciler — it ships two features the React/Next architecture can't produce without a major rework. Both are opt-in and tree-shake out of apps that don't use them.
- Live Server Components. Wrap a server-rendered subtree in
<Live tags={["orders"]}>and denext re-renders just that boundary — under the viewer's own session — and pushes it over a WebSocket whenever one of its cache tags is invalidated from anywhere (a Server Action, a webhook, a cron). No polling, no client-side data fetching, and every other component's state is preserved. Next re-renders RSC segments only when the client asks (a navigation,router.refresh()); it has no first-party way to push an update to idle clients when the data changes elsewhere. The real-time family —useLive,usePresence,useLiveOptimistic— rides the same socket: a Convex/Liveblocks-class layer with zero npm and zero extra infra. - Resumability. Add
export const resumable = trueto a route and it's interactive with no up-front hydration — and plain components work unchanged (useState+onClick, no special API). Each island wakes on first interaction (the triggering event is replayed to the just-resumed handler) or on idle for effects; only the touched island resumes, anduseSignalstate is adopted rather than recomputed. Qwik pioneered this model; React hydrates the whole tree up front and Next inherits that cost.
Both require a Flight (RSC) route. See Features below and the full ledger in FEATURES.md.
-
App Router — folder-based
app/routing withpage,layout,route(API), and the special filesloading,error,not-found. Static, dynamic[slug], catch-all[...rest], optional catch-all[[...rest]], route groups(group), parallel@slotand intercepting(.)/(..)/(...)routes. -
SPA mode — for a client-only app ("React but not Next"), set
mode: "spa"indenext.config.ts: denext bundles a single client entry, wraps it in an HTML shell, and serves that shell for every navigation (history-API fallback) — noapp/directory, no SSR. Bring your own router (TanStack, etc.) and data layer; you still get the Deno-native bundler, the CSS pipeline, live reload, and single-binarydeno desktoppackaging. The on-ramp for hosting an existing Vite-style React SPA on denext's small, zero-npm runtime. Seeexamples/spa. -
Typed API, end to end —
defineApi({ params, query, body, response, errors }, handler)validates a route handler through any Standard Schema (Zod, Valibot, ArkType, TypeBox, hand-rolled) before your code runs; the generated.denext/api.tstypescreateApiClient()anduseApiagainst the routes themselves (path, method, params, query, body, response, error codes) — no tRPC, no client codegen. The client dedupes, batches GETs into one request, and runs in-process during SSR.defineSubscription/useSubscription(validated live queries) andcreateChannel/useChannel(authorized server push) ride the Live socket. Two plugins finish the surface:@denext/openapiturns the same definitions into/openapi.json+ a docs page +denext openapifor CI, and@denext/graphqlmounts GraphQL Yoga with subscriptions over channels. See docs → Typed API. -
i18n routing — optional default-locale prefix (
/about= default,/fr/about=fr); the locale lands inparams.localeand in theuseLocale()hook, withAccept-Language/cookie negotiation vialocaleMiddleware. -
Server-side rendering — a self-contained JSX runtime renders function components (sync and async) to HTML, with correct escaping, context, and metadata.
-
Client hydration — a small virtual-DOM reconciler hydrates server markup in place with real hooks (
useState,useEffect,useReducer,useMemo,useRef,useContext) and keyed reconciliation. -
React DevTools — the reconciler registers with the React DevTools extension and reports its tree as fibers, so the extension recognizes a denext app and shows the component tree (a cheap no-op when the extension isn't installed; guarded so it can never affect rendering).
-
React & Next.js compatibility — reconciler-level fidelity (context-preserving portals, real refs,
react-is, RadixasChild/Slot, React event semantics) plusnext/*, fullNextRequest/NextResponse,next-intl,next/font, and abetter-sqlite3shim — all via import aliases, no npm added to the runtime (see React & Next.js compatibility). -
Concurrent rendering (fiber) — a resumable, double-buffered fiber reconciler:
useTransition/useDeferredValuerenders are time-sliced (yield to paint/input) and interruptible (an urgent update restarts them), committed atomically off-DOM. Effects split into a synchronous layout phase and a scheduled passive phase. The default (sync) lane stays synchronous. -
Suspense + streaming —
<Suspense>,use(), andcreateResource()with streaming SSR (renderToReadableStream) that flushes fallbacks first and streams resolved content progressively. -
Error boundaries & 404s —
error.tsxboundaries withreset(), andnotFound()→ real404.useErrorBoundary()(captureError/reset) plus automatic catching of errors thrown in event handlers and form actions — things React can't catch. -
Middleware — root
middleware.ts(orproxy.ts) as a single handler or an ordered array (composed chain) withredirect,rewrite,next+ header injection, and a pathmatcher. -
Client navigation —
<Link>(with hover/viewport prefetch),useRouter,usePathname,useSearchParams,useParams, and SPA soft navigation with history support. -
Server Actions —
serverAction(id, handler)dispatched over a secure, same-origin-enforced RPC endpoint; usable as a<form action>with no-JS progressive enhancement or viauseActionState. -
Authentication — first-party
denextAuth: OAuth 2.0 / OIDC (Authorization Code + PKCE) with Google / GitHub / generic-OIDC presets plus an email-password Credentials provider, on signed__Host-cookie sessions (no tokens stored). Add it as a plugin and the/auth/*endpoints mount automatically — read the session withauth(), gate routes withrequireAuth(), and useuseSession/signIn/signOuton the client.id_tokens are JWKS-verified across the RS/PS/ESalgfamilies, provider calls go through the SSRF-safesafeFetch, and theredirect_uriis pinned to a canonical origin. Zero npm. -
Caching & ISR —
cache(),unstable_cache,revalidatePath/revalidateTag, route segment config (export const dynamic/revalidate), and a per-route production page cache (opt-in; default pages stay dynamic). The default store is durable: Deno's built-innode:sqliteat.denext/cache.db(real native SQLite — zero npm, no unstable flag, survives restarts); the in-memory store is the fallback where the filesystem is read-only (Deno Deploy). Point it elsewhere or swap it explicitly:import { setCacheStore, sqliteCacheStore } from "denext/server"; setCacheStore(sqliteCacheStore({ path: "/var/lib/app/cache.db" }));
sqliteCacheStoreuses Deno's built-innode:sqlite(real native SQLite, zero npm, no unstable flag). For multi-replica deployments (e.g. Deno Deploy, where there's no durable local disk) the default resolver falls back to the in-memory store per replica; for a cache shared across replicas — sorevalidateTag/revalidatePathreach every instance — implement theCacheStoreinterface against a shared backend (Redis, etc.). -
Live Server Components — wrap a server-rendered subtree in
<Live tags={["orders"]}>; when one of its cache tags is invalidated (revalidateTag/updateTag, from anywhere — a Server Action, a webhook, a cron), the server re-renders just that boundary under the viewer's own session and pushes it over a WebSocket, reconciled in place — no polling, no client-side data fetching, and all other component state preserved. Next.js re-renders RSC segments too, but only when the client asks (a navigation,router.refresh(), the user's own action) — it has no first-party way to push an update to idle clients when the data changes elsewhere. Opt-in via@denext/denext/live; the socket only opens once a<Live>boundary mounts, so apps that don't use it bundle none of the transport. Requires a Flight (RSC) route. The same socket carries the real-time data family —useLive(action, args, { tags })(subscribe to a server function's result, re-run under the viewer's session on tag invalidation),usePresence(room)(who's-online / cursors), anduseLiveOptimistic— a Convex/Liveblocks-class layer with zero npm and zero extra infra. -
Resumability — opt a route in with
export const resumable = trueand it's interactive with no up-front hydration: plainuseState+onClickcomponents work unchanged, each island wakes on first interaction (the event is replayed to the just-resumed handler) or on idle for effects, anduseSignalstate is adopted rather than recomputed. Finer control is available per island viaclient:load|idle|visible|interactiondirectives, per-handler viaqrl()code-split handlers, and reactive serializable state viauseSignal/useStore. Off by default (a route keeps React-style hydration until it opts in) and the whole runtime tree-shakes out of apps that don't use it. Requires a Flight (RSC) route; see FEATURES.md. -
SEO —
app/sitemap.ts,robots.ts,manifest.ts,favicon.ico,generateMetadata, and React 19 in-tree<title>/<meta>/<link>hoisting. -
Assets —
<Image>(with opt-in, allowlisted remote optimization),<Script>strategies, and self-hosted fonts (localFont, plusnext/font/localandnext/font/googleunder next-compat). -
Scaffolding —
denext create/denext initgenerate a ready-to-run project (with prompts for Tailwind, asrc/layout, the compiler, and native desktop/mobile targets). -
Desktop & mobile — scaffold a native desktop app (Deno 2.9
deno desktop) and/or iOS/Android (Capacitor) from the same codebase; both ship the static export. See Desktop & mobile. -
Tailwind, built in — denext downloads and runs the Tailwind v4 standalone binary itself (zero npm); just point
denext.config.tsat your input/output. -
Memoization — a context-aware reconciler bailout,
memo(),useMemoCache, and an experimental auto-memo compiler (React-Compiler-style, opt-in) that keeps unchanged subtrees stable. -
Toolchain —
create/dev(live reload)/build/start/export, powered bydeno bundle(and esbuild on the next-compat / SPA-compat path, for running unmodified npm-React apps). No webpack, no bundler config to write, nonode_modulesfor a denext-native app.
Ship the same denext app to the web, the desktop, and mobile. Both native
targets serve denext's static export (deno task export → out/). Scaffold
them up front:
deno run -A jsr:@denext/denext/cli create my-app --desktop --capacitor
Desktop (via deno desktop, Deno
2.9+): a generated desktop.ts is a Deno.serve() over the export that
deno desktop wraps in a native WebView window (single self-contained binary —
no Chromium).
deno task desktop # export + open a native window
deno task desktop:package # export + build a distributable (./dist/)
Mobile (via Capacitor): a generated
capacitor.config.ts bundles the export (webDir: "out") into native
iOS/Android shells.
deno install # Capacitor's CLI + platforms are npm packages
deno task mobile:sync # export + copy assets into the native projects
deno task mobile:ios # open in Xcode (deno task mobile:android → Android Studio)
A complete project wired for all three is in
examples/native. Native builds are experimental
(deno desktop) and need the platform toolchains (Xcode / Android Studio) for
mobile.
Compatibility is the on-ramp: your Next.js knowledge transfers directly, and
much of the React/Next ecosystem runs on denext unmodified because denext is
React at the reconciler level, not merely a name-match. Bringing an existing
App Router app over? denext migrate converts its package.json to a
deno.json (react/next aliases, dep classification) so
denext build && denext start runs it on denext's single React — see
Migrating from Next.js and the honest caveats in
Status & limitations.
Turn compat on per project by aliasing the specifiers in your import map
(denext create --compatibility or denext migrate writes these for you):
React. @denext/denext/react re-exports denext's hooks and helpers under
their React names — createElement, Fragment, every use* hook (incl.
useEffectEvent), memo, createContext, Suspense, lazy (= dynamic),
plus forwardRef, Children, cloneElement, isValidElement, and a default
React object. @denext/denext/react-dom provides
createRoot/hydrateRoot/flushSync, legacy render/hydrate, and a real
createPortal — backed by a first-class reconciler portal, so the portaled
subtree keeps its place in the context tree (context providers and error
boundaries above the call are visible across the portal, exactly like React).
With denext's React DevTools support, the ecosystem — and your
tools — see denext as React.
Reconciler-level primitives the component ecosystem (Radix UI / shadcn/ui, react-hook-form, emotion) leans on:
react-isclassifies denext elements and brandedforwardRef/memo/lazy/Suspense/portal/fragment components (isForwardRef,isMemo,typeOf, …).Slot/Slottable+composeRefs(@denext/denext/slot,/compose-refs) implement Radix'sasChildpattern — merge props onto a single child element (className joins, event handlers compose, refs merge), with no wrapper element.- Real refs — object and callback refs, forwarded through components, detached on unmount, with React-19 cleanup-returning callback refs honored.
- React event semantics —
onChangemaps to the DOMinputevent, andon*Captureregisters capture-phase listeners.
Next.js. next/* maps App-Router APIs to denext: next/link, next/image,
next/script, next/dynamic, next/navigation, next/headers, next/cache,
next/og, and next/server — where NextRequest (nextUrl, cookies,
ip/geo) and NextResponse (a Response subclass with a .cookies
writer) are full implementations that interoperate with denext's middleware
runner. next/font/local and next/font/google self-host fonts and return the
usual { className, style, variable } handle.
next-intl is covered end-to-end —
useTranslations/useLocale/useFormatter/ NextIntlClientProvider, the
next-intl/server getters, locale-aware next-intl/navigation, and
next-intl/middleware — over a compact ICU MessageFormat built on the standard
Intl.* APIs.
Data. better-sqlite3 runs via a shim over Deno's built-in node:sqlite
(the native npm addon can't load under Deno), covering
prepare/get/all/run, pluck/raw, pragma, and transactions (nesting
via savepoints).
Every one of these rides Deno built-ins, @std/*, Intl.*, or node:sqlite,
and image optimization / the durable cache ride denext's own first-party
@denext/* JSR wasm codecs — no npm is added to denext's runtime, and a CI
guard now enforces that across the entire runtime (not just the compat layer).
Honest limits. denext is function-components-first, but React class
components are supported — full lifecycle, setState batching,
getDerivedStateFromProps, shouldComponentUpdate/PureComponent,
getSnapshotBeforeUpdate, error boundaries, and legacy contextType — so real
npm libraries built on classes (e.g. recharts) run. The class runtime is always
on in the standard build; the next-compat build gates it behind
classComponents for zero-cost dead-code elimination when unused. The compat
modules match React/Next behavior and shapes, and denext now has its own
fiber reconciler (time-sliced, interruptible concurrent rendering), but it is
not React internally — anything reaching for react-reconciler,
react-dom/server streaming internals, or React's own fiber data structures is
out of scope. Running an npm package's own import "react" on denext needs
that specifier rewritten to denext even inside the package (Deno's managed npm
resolution doesn't follow a top-level import-map alias into node_modules); the
next-compat build does this for both client and server modules, so an unmodified
App Router app builds and runs on denext's single React. The honest caveat:
deno check on such an app still reports cross-library @types/react conflicts
(npm libs ship their own React types) — runtime rendering is unaffected, but
type-checking isn't clean. And the ICU subset covers interpolation,
plural/selectordinal, select, number/date formatting, and apostrophe escaping —
not the entire spec.
- Deno ≥ 2.9 (
denext doctorchecks it).build/devbundle client code by shelling out to Deno's owndeno bundle— an experimental, still-evolving subcommand — so a Deno 2.xdenobinary must be reachable. denext checks the version up front and fails with a clear message on an older or missing binary; point it at a specific Deno withDENO_BIN=/path/to/deno. A build-output smoke test in the suite guards againstdeno bundleoutput-shape drift between Deno releases.
The fastest way is the scaffolder — it writes deno.json, an app/, and an
example page for you:
deno run -A jsr:@denext/denext/cli create my-app # new project (prompts for options)
cd my-app
deno task dev
On a terminal, create/init present the options as a single multi-select (↑/↓
move · space toggle · enter confirm):
Select features (↑/↓ move · space toggle · enter confirm)
› ◉ Tailwind CSS
◯ src/ directory layout
◯ Auto-memo compiler (experimental)
◉ Native desktop app (deno desktop)
◯ iOS / Android (Capacitor)
denext create <dir> scaffolds a new/empty directory; denext init scaffolds
into the current directory without overwriting existing files. Both accept
--tailwind, --src-dir, --compiler, --desktop, --capacitor, and --yes
(flags pre-check the corresponding options and, with --yes, skip the prompt
entirely).
To wire a project up by hand instead, create an app/ directory next to a
deno.json:
my-app/
├─ deno.json
├─ app/
│ ├─ layout.tsx
│ ├─ page.tsx
│ └─ api/hello/route.ts
└─ public/
deno.json needs the denext JSX toolchain and import map:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "denext",
"lib": ["deno.window", "dom", "dom.iterable", "dom.asynciterable"]
},
"imports": {
"denext": "jsr:@denext/denext",
"denext/jsx-runtime": "jsr:@denext/denext/jsx-runtime",
"denext/server": "jsr:@denext/denext/server",
"denext/client": "jsr:@denext/denext/client"
}
}Then run the CLI (see The denext command for nicer ways
to invoke it):
deno run -A jsr:@denext/denext/cli dev . # dev server + live reload
deno run -A jsr:@denext/denext/cli build . # produce .denext/ bundles
deno run -A jsr:@denext/denext/cli export . # static export (SSG) to out/
deno run -A jsr:@denext/denext/cli start . # serve the production build
See examples/hello for a complete working app.
Instead of typing deno run -A .../cli.ts every time, get a real denext
command one of these ways:
1. Install it globally (a thin launcher that still uses your installed Deno):
deno install -A -g -n denext jsr:@denext/denext/cli
denext dev # in a project folder with app/ + deno.json
2. Compile a standalone binary (bundles the Deno runtime — no Deno needed to run it):
deno task compile # produces ./denext (deno compile -A --output denext cli.ts)
./denext build .
./denext start . # fully standalone: serves prebuilt bundles
Note:
devandbuildproduce browser bundles by shelling out todeno bundle, so those two subcommands still require adenobinary on the machine (found viaDENO_BIN,~/.deno/bin/deno, orPATH).startonly serves already-built output, so a compileddenext startneeds nothing else. SetDENO_BIN=/path/to/denoto point at a specific Deno.
3. A project task — add to your app's deno.json (what examples/hello
does):
{
"tasks": {
"dev": "deno run -A jsr:@denext/denext@^2/cli dev .",
"build": "deno run -A jsr:@denext/denext@^2/cli build .",
"start": "deno run -A jsr:@denext/denext@^2/cli start ."
}
}Then deno task dev, deno task build, deno task start.
denext publishes to JSR as @denext/denext with these entry
points:
| Import | Contents |
|---|---|
@denext/denext |
components, hooks, renderToString, Link, … |
@denext/denext/server |
serve, createApp, middleware helpers, server types |
@denext/denext/client |
hydrateRoot, createRoot, hooks, navigation |
@denext/denext/jsx-runtime |
the JSX runtime (jsxImportSource target) |
@denext/denext/cli |
the create/dev/build/start CLI |
@denext/denext/lint-plugin |
the deno lint plugin |
@denext/denext/compiler-runtime |
the auto-memo compiler's runtime target (generated code) |
A consuming project's deno.json maps the bare denext specifiers used in app
code and generated bundles to the package:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "denext",
"lib": ["deno.window", "dom", "dom.iterable", "dom.asynciterable"]
},
"imports": {
"denext": "jsr:@denext/denext",
"denext/jsx-runtime": "jsr:@denext/denext/jsx-runtime",
"denext/server": "jsr:@denext/denext/server",
"denext/client": "jsr:@denext/denext/client"
},
"lint": { "plugins": ["jsr:@denext/denext/lint-plugin"] },
"tasks": { "dev": "deno run -A jsr:@denext/denext/cli dev ." }
}That's the whole install: no node_modules, no lockfile churn — Deno fetches
the package on first run.
Published from this repo with
deno publish. Newly-published versions are subject to Deno's minimum-dependency-age policy — pass--min-dep-age=0(or wait ~24h) to import one immediately.
denext patch is patch-package for denext: edit a dependency where it is installed, record the
edit as a reviewable patches/<name>+<version>.patch, and every dev/build/start/export
re-applies it before loading the app (idempotently, so a reinstall heals at the next start).
denext patch edit left-pad index.js # prints node_modules/left-pad/index.js — edit it
denext patch create left-pad # → patches/left-pad+1.3.0.patch
denext patch list # indexed; denext patch delete <name|index> revertsIt works on the framework too, straight from JSR: denext patch edit denext src/server/document.ts
copies the pristine source to patches/.work/denext/…; edit it; denext patch create denext writes
patches/denext+<version>.patch, materializes the patched file into patches/denext/ (relative
imports absolutized) and maps the file's full JSR URL to it in deno.json's import map — Deno applies
import maps to the framework's own relative imports, so one published file is overridden without
vendoring the package. Compat builds apply the same diff in memory when they prebuild the runtime.
npm patches need a node_modules directory (nodeModulesDir: "auto" or "manual"). Docs:
denext.dev/docs/patches.
Optional config, loaded once at startup (as a default export or named exports):
import type { DenextConfig } from "denext/server";
export default {
// Tailwind CSS — denext downloads/manages the v4 standalone binary (zero npm)
// and compiles input → output automatically on dev/build.
tailwind: { input: "styles/tailwind.css", output: "app/globals.css" },
// Remote image optimization is off by default (local-only, SSRF-safe). Allowlist
// hosts to enable it for the /_denext/image endpoint.
images: {
remotePatterns: [{
protocol: "https",
hostname: "*.example.com",
pathname: "/img/",
}],
},
// Experimental auto-memo compiler (default off).
experimental: { reactCompiler: true },
} satisfies DenextConfig;Tailwind. Point tailwind.input at a stylesheet containing
@import "tailwindcss"; and import the compiled output from your layout.
denext runs the standalone binary for you; override it with TAILWIND_BIN or
pin a version with DENEXT_TAILWIND_VERSION. denext create --tailwind sets
all of this up.
src/ directory. If a src/app directory exists, denext puts app/,
middleware, and instrumentation under src/ (Next.js parity); public/,
config, and .denext stay at the project root. denext create --src-dir
scaffolds it.
Operational hooks. serve() / createApp() accept onRequest(info) for
per-request logging/metrics — info carries method, path, status,
durationMs, and a requestId (which is also echoed as the x-request-id
response header on an error, for correlation). Or set DENEXT_LOG=1 for a
compact one-line-per-request logger, or DENEXT_LOG=json for one structured
JSON object per request (with a statusClass field), ready to ingest into a log
pipeline. requestTimeout (ms) responds 503 when exceeded.
Client-side instrumentation. A root instrumentation-client.{ts,tsx,js} (Next's
convention) is bundled into every browser entry and runs before the app's client code
starts — the place for a monitoring/analytics init.
OpenTelemetry recipe. Wire onRequest to a histogram and onRequestError
(from instrumentation.ts) to your tracer/error sink:
// instrumentation.ts
export function onRequestError(err, request, ctx) {
tracer.recordException(err, {
"http.route": ctx.routePath,
"http.url": request.url,
});
}
// serve.ts
serve({
getManifest,
onRequest: (i) =>
httpDuration.record(i.durationMs, {
"http.method": i.method,
"http.status_code": i.status,
"http.status_class": `${Math.floor(i.status / 100)}xx`,
}),
});Ops runbook (essentials).
- Health:
cacheStoreHealthy()probes the active cache backend without throwing — expose it on a/healthzroute for readiness checks. - Correlate an error: a
500returns anx-request-idheader; grep the logs (DENEXT_LOG=json) for thatrequestIdto find the full server-side error and digest. - Runaway request: bounded by
requestTimeout(default 30s →503); the render is signal-aware, so a client disconnect or timeout actually cancels the work. - Graceful shutdown: on
SIGINT/SIGTERMthe server stops accepting connections and drains in-flight requests before exiting (abort theserve()signal to trigger). - Cache backend down: reads/writes are best-effort — requests serve uncached
and errors are logged (rate-limited per operation), never surfaced as
500s.
denext's reconciler bails out of re-rendering a component whose props are
shallow-equal and whose visible context is unchanged — context changes still
reach deep consumers correctly. Use memo(Component, areEqual?) for an explicit
custom comparator, and useMemoCache (on denext/compiler-runtime) as the compiler's stable-cache primitive.
The experimental auto-memo compiler (experimental: { reactCompiler: true } — Next.js's key; the pre-2.0 compiler is a deprecated alias — or
denext create --compiler) goes further: a build-time pass lifts JSX component
elements into useMemoCache-guarded memo calls so unchanged subtrees keep a
stable reference and skip re-rendering — the same idea as the React Compiler. It
transforms only the client bundle (server output is unchanged, so SSR/hydration
stay aligned), bails to identity on anything it cannot analyze, and is off by
default.
| File | Meaning |
|---|---|
app/page.tsx |
Page at / |
app/about/page.tsx |
Page at /about |
app/blog/[slug]/page.tsx |
Dynamic page; params.slug |
app/docs/[...path]/page.tsx |
Catch-all; params.path is ["a", "b", "c"] (Next.js shape) |
app/layout.tsx |
Wraps this segment and everything beneath it |
app/template.tsx |
Like a layout, but conceptually re-mounted |
app/loading.tsx |
Suspense fallback for the segment |
app/error.tsx |
Error boundary ({ error, reset }) |
app/global-error.tsx |
Root error boundary — replaces the whole tree |
app/not-found.tsx |
Not-found UI (notFound() or unmatched routes) |
app/forbidden.tsx |
403 UI (forbidden()) |
app/unauthorized.tsx |
401 UI (unauthorized()) |
app/api/x/route.ts |
API endpoint exporting GET/POST/… |
app/(group)/… |
Route group — folder name omitted from the URL |
app/@slot/page.tsx |
Parallel route — rendered into the layout as a named prop |
app/(.)x/page.tsx |
Intercepting route — matches on soft-nav only ((.)/(..)/(...)) |
middleware.ts / proxy.ts |
Runs before routing (single handler or ordered array) |
// Components & rendering
import {
createContext,
createResource,
ErrorBoundary,
Link,
notFound,
renderToReadableStream,
renderToString,
startTransition,
Suspense,
use,
useCallback,
useContext,
useDeferredValue,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useMemo,
usePathname,
useReducer,
useRef,
useRouter,
useSearchParams,
useState,
useSyncExternalStore,
useTransition,
} from "denext";
// Typed API client (typed against ./.denext/api.ts once it is imported as a type)
import { createApiClient, isApiClientError, useApi } from "denext";
// Context is also usable directly as a provider: <MyContext value={v}>…</MyContext>
// Server helpers & types
import {
createApp,
next,
redirectResponse, // middleware helpers (Response-returning; `redirect` is the deprecated alias)
renderPage,
rewrite,
serve,
} from "denext/server";
import type { ApiContext, LayoutProps, Metadata, PageProps } from "denext/server";
// Typed API surface (server side)
import {
ApiError,
createApi,
createChannel,
defineAction,
defineApi,
defineSubscription,
rateLimit,
requireSession,
tapChannel,
} from "denext/server";
import { useChannel, useSubscription } from "denext/live";
// Client runtime
import { createRoot, hydrateRoot } from "denext/client";// app/blog/[slug]/page.tsx — async server component
export default async function Post({ params }: PageProps) {
const post = await db.get(params.slug); // runs on the server
return (
<article>
<h1>{post.title}</h1>
</article>
);
}
// app/api/hello/route.ts
export function GET(req: Request, ctx: ApiContext) {
return Response.json({ hello: ctx.params });
}Page components receive
{ params, searchParams }— not the rawRequest. Read per-request data (cookies, headers) withcookies()/headers()fromdenext/server; both mark the render dynamic, so a personalized page is never shared from the ISR cache. (Route handlers still get theRequestdirectly.)
// middleware.ts (proxy.ts also works)
import { next, redirectResponse } from "denext/server";
export default function middleware(req, ctx) {
if (!ctx.url.pathname.startsWith("/app")) return next();
return req.headers.get("cookie") ? next() : redirectResponse("/login");
}
export const config = { matcher: "/app/:path*" };denext ships a Deno-native lint plugin (src/lint/denext-plugin.ts) — no
ESLint, no npm. Enable it in your deno.json:
{ "lint": { "plugins": ["<path-to-denext>/src/lint/denext-plugin.ts"] } }Then deno lint enforces React/denext hook rules:
| Rule | Catches |
|---|---|
denext/rules-of-hooks |
Hooks called conditionally (in if/loops) — order must be stable |
denext/hooks-in-component |
Hooks called outside a component/useX hook (e.g. in callbacks) |
denext/no-hooks-in-async |
Hooks in an async server component (never hydrates) |
function Comp() {
if (cond) {
const [n] = useState(0); // ✗ denext/rules-of-hooks
}
}src/
├─ jsx/ JSX runtime, renderToString, renderToReadableStream (streaming)
├─ runtime/ hooks, context, Suspense, error boundaries
├─ router/ segment parsing/matching + filesystem manifest scanner
├─ server/ request handler, page pipeline, API dispatch, static, middleware
├─ client/ virtual-DOM reconciler, hydration, soft navigation
└─ build/ deno-bundle integration, dev server, prod server, CLI wiring
cli.ts dev | build | start | export | create | generate | migrate | codemod | doctor | mcp | …
mod.ts public "denext" entry point
- No React.
src/jsx+src/runtime+src/clientare a compact React-equivalent (function components, the common hooks, context, Suspense, hydration, keyed diffing). - No bundler dependency.
deno bundletranspiles JSX and bundles each route into a single browser module.
deno task test # run the test suite
deno task lint # deno lint (incl. the denext hook rules)
deno task fmt # format the codebase
deno task check:fix # fmt + lint --fix, then report what's left
deno task check # fmt --check + lint + test
deno task release-check # check + doc-lint + publish --dry-run
check:fix is the write counterpart to check: it runs deno fmt and
deno lint --fix to apply every auto-fixable formatting and lint change, then a
final report-only deno lint surfaces the rules that have no auto-fix so you
can handle them by hand.
An opt-in pre-commit hook runs check:fix before each commit (fast — format
and lint only, no tests; the suite stays in CI). Enable it once per clone:
deno task hooks:install # git config core.hooksPath .githooks
Contributing and releasing are documented in
CONTRIBUTING.md.
The suite covers the JSX runtime, SSR (string + streaming), the router and manifest scanner, the request handler, static serving, the client reconciler (hydration, keyed reordering, effects, context), Suspense, error boundaries, middleware, client navigation, and the lint plugin.
Formatting is Deno's built-in deno fmt (no Prettier/npm), configured in
deno.json:
{
"fmt": {
"useTabs": false,
"lineWidth": 100,
"indentWidth": 2,
"semiColons": true,
"singleQuote": false,
"proseWrap": "preserve",
"exclude": [".denext/", "examples/*/.denext/", "dist/"]
}
}Adjust these to taste — e.g. "singleQuote": true, "lineWidth": 80, or
"useTabs": true — then run deno fmt. Your own denext projects get the same
knobs in their own deno.json.
denext ships hardened by default — same-origin/POST-only Server Actions,
SSRF-pinned image optimization, strict attribute escaping, path-traversal-safe
static serving, a default CSP, and hardening response headers, several of which
close Next.js CVE classes. The full threat-by-threat posture lives in
CVE-DEFENSE-GUIDE.md (the canonical security doc), and
the mechanism-by-mechanism ledger (file:line, [default]/[opt-in] labels) is
FEATURES.md §"Where denext beats Next/React → Security".
What's still your responsibility at the app/edge layer:
-
Fetching a user-supplied URL? Use
safeFetch, notfetch. For link previews, "import from URL", avatar-by-URL, webhooks, etc.,safeFetch(fromdenext/server) resolves + validates the host, refuses internal addresses, pins the connection (closing DNS rebinding), and bounds time/size:import { safeFetch, SafeFetchError } from "denext/server"; try { const res = await safeFetch(userUrl, { allowedHosts: ["*.trusted-cdn.com"], // optional; omit = any public host maxBytes: 5_000_000, signal: AbortSignal.timeout(8000), // or an AbortController's signal }); } catch (e) { if (e instanceof SafeFetchError) { /* e.code: "blocked-address", … */ } }
Keep using
fetch/cachedFetchfor your own backends (internal services,localhost) — those are addressessafeFetchdeliberately blocks. -
dangerouslySetInnerHTMLandmetadata.heademit raw HTML — never pass unsanitized user/CMS content to them. -
Redirecting to a user-controlled target? Validate it first. Config-driven
redirects()and the middlewareredirect()helper both normalize their location throughsafeRedirectLocation(a//hostor/\hostprefix can't escape your origin). But an explicit absolute URL is passed through verbatim (that's intended — you asked to leave the origin), soredirect("https://" + userInput)is still an open redirect. Allowlist a user-controlled destination before redirecting to it. -
absoluteUrl/requestOriginderive the origin from theHostheader by default (forwarded headers are ignored unless you opt in withtrustForwardedHeaders). A client can spoofHost, so for a fixed public origin setcanonicalOrigin— it overrides the header and is the robust choice for canonical/og:imageURLs. -
Middleware matchers see the locale-stripped path. Under
i18n, amatcher: "/admin/:path*"fires for/fr/admin/xas well as/admin/x(the matcher is tested against the path with the locale prefix removed, as in Next.js), so a locale prefix can never route around a path-restricted middleware.ctx.locale/req.nextUrl.localecarry the peeled locale. -
Don't build a redirect/rewrite destination host from request input. A config rule like
{ destination: "https://:host/..." }substitutes a URL param into the host — an open redirect. (Arewriteto an external host is not an SSRF in denext — rewrites re-route by pathname against your local manifest and never proxy — but it is still a misconfiguration.) Keep params in the path. -
Run production with least privilege. The example tasks use
-Afor convenience; in production grant only whatdenext startneeds — it serves prebuilt output and does not bundle, so it never needs--allow-run:deno run --allow-net --allow-read=. --allow-env jsr:@denext/denext/cli start .(
dev/build/exportre-exec a child bundler; that child now inherits the parent's actual grants instead of a blanket-A, so narrowing the parent narrows the child too.) -
Bound request sizes and rate-limit at your edge/proxy — denext caps action bodies and image sources, but a proxy-level limit and rate limiting are still the right place for broad DoS protection.
denext is a from-scratch implementation of the Next.js core. It is
function-components-first; the App Router is the core, with the legacy pages/
router available as an optional first-party plugin (@denext/pages-router). Its
client reconciler is fiber-based: transition-lane renders are time-sliced,
interruptible, and committed atomically; effects are split into a synchronous
layout phase and a scheduled passive phase; and the sync lane stays synchronous
(see the migration guide's §10). Class components are supported for running real
npm React libraries through the next-compat build (opt-in via
classComponents), not in the default function-component runtime. Client-side
navigation is a soft nav that reconciles the new route in place on the retained
reconciler root (no full-page reload): a Flight route (one with a
"use client"/"use server" boundary) transfers just its RSC/Flight payload
and re-runs no route bundle, while an isomorphic (non-Flight) route still
re-fetches the full HTML document and re-runs its route bundle — see
KNOWN-LIMITATIONS.md.
The dev server bundles each route independently and lazily for fast
rebuilds, whereas denext build runs a single code-split pass that hoists the
client runtime into one shared chunk. A production page shares exactly one
runtime instance across route entries; the dev server does not guarantee that.
The production build is the source of truth for runtime-singleton behavior, so
verify a release against denext build output, not only the dev server.
Contributions and issues welcome.
Each doc owns one job, so the same fact lives in exactly one canonical place:
- FEATURES.md — the master list of everything denext ships,
and the ledger of where denext beats React/Next (with
file:linemechanisms and[default]/[opt-in]labels). The canonical home for the feature/enhancement set. - README-NEXT-MIGRATION.md — migrating a Next.js
app to denext; the canonical home for next-compat,
classComponents, and the concurrency model. - DEPLOYMENT.md — production deployment & the operational responsibilities denext leaves to your edge (concurrency, SSRF-pinning, CSP, proxy origin).
- DATABASE.md — databases & ORMs on denext.
- PLUGINS.md — the plugin contract.
- ARCHITECTURE.md — how denext differs underneath the React surface (own reconciler, async SSR, soft-nav, Pages-Router-as-plugin) — design choices, not limitations.
- KNOWN-LIMITATIONS.md — the genuine React/Next surface gaps and the bounded scope of denext's own capabilities (what is missing or wrong today; deliberate differences are in KNOWN-DIFFERENCES).
- KNOWN-DIFFERENCES.md — where denext deliberately behaves differently from React/Next (documented, not gaps).
- CVE-DEFENSE-GUIDE.md — the canonical, threat-by-threat security posture vs the ecosystem's CVEs.
- SECURITY.md — supported versions and how to report a vulnerability privately.
- CONTRIBUTING.md — the check/lint gate, conventions, and the JSR release flow.
- ROADMAP.md — what still needs doing (the rest of the 2.1 cycle: build-time WASM codecs, router plugins).
- CHANGELOG.md — release history.
MIT
