Skip to content

Nexide logo

CIReleaseGHCRTestsLicense: MIT OR Apache-2.0RustV8Status

A native Rust runtime for serving Next.js applications. No Node.js. No Deno.

Nexide embeds a pool of raw V8 isolates behind an Axum/Tower HTTP server, and exposes just enough of the Node.js compatibility surface for the Next.js standalone server bundle to boot and serve traffic. It is not a general purpose JavaScript runtime; it is a single-purpose Next.js host.


Status

Experimental. The runtime boots a real Next.js 16 standalone bundle, serves SSR, RSC, ISR, SSG and route handlers correctly, passes 266 unit + integration tests, and runs continuously under load. It has not been hardened for production traffic and the public Rust API is unstable.

Use it for benchmarking, research, internal tooling, and giving feedback. Do not put your customers on it yet.

Why

Node.js is the de facto Next.js host but pays a real tax on cold start, memory footprint and per-request overhead. Deno improves on parts of that but is not optimised for the Next.js critical path. Nexide asks a narrower question:

If we own the entire stack (HTTP, isolate lifecycle, Node API surface), how fast can a Next.js standalone bundle actually serve?

Benchmarks

All numbers below come from nexide-bench docker-suite: 5 routes × 3 runtimes × 4 container presets × 30 s window @ 64 connections, on identical Docker images of the same Next.js 16 application.

Runtimes: nexide (this repo, the binary name), node (Node 22 LTS standalone server), deno (Deno 2.x with --unstable-node-modules-dir).

1 vCPU, 512 MB RAM (typical small container)

RouteRuntimeRPSp50p95p99Mem avgCPU avg
api/pingnexide4 6649.9ms39.7ms52.0ms171 MB97.9%
api/pingnode2 56218.9ms47.7ms59.7ms60 MB97.9%
api/pingdeno2 99414.1ms54.4ms61.2ms103 MB97.3%
api/timenexide4 65810.0ms40.4ms54.4ms146 MB97.8%
api/timenode2 53119.0ms47.7ms64.8ms60 MB97.8%
ssg/aboutnexide14 1304.3ms6.2ms7.2ms174 MB92.3%
ssg/aboutnode3 34315.3ms41.1ms51.1ms59 MB98.4%
rsc/aboutnexide20 8462.7ms4.8ms9.0ms173 MB104.1%
rsc/aboutnode3 36015.0ms41.4ms53.7ms59 MB98.4%
_next/imagenexide13 1594.8ms6.5ms7.5ms175 MB29.2%
_next/imagenode5 9346.5ms41.6ms52.3ms69 MB97.6%
_next/imagedeno5 5687.3ms41.3ms51.8ms126 MB97.9%

The _next/image row is the headline: same image, same query string, same Accept: image/webp — Nexide answers it from a native Rust pipeline (decode → resize → encode → in-memory LRU + on-disk cache) and never enters V8. CPU sits at ~30% under the same load that pegs Node and Deno at 100%.

RPS uplift vs Node.js across all four container presets

Presetapi-pingapi-timessgrsc_next/image
1 vCPU / 256 MB+71.1%+64.9%+303.1%+478.1%+110.9%
1 vCPU / 512 MB+82.1%+84.1%+322.7%+520.4%+121.8%
1 vCPU / 1024 MB+58.1%+76.5%+302.7%+490.6%+52.9%
2 vCPU / 1024 MB+145.2%+130.5%+230.7%+394.3%+6.0%

RPS uplift vs Deno across all four container presets

Presetapi-pingapi-timessgrsc_next/image
1 vCPU / 256 MB+49.8%+46.9%+261.3%+411.9%+112.7%
1 vCPU / 512 MB+55.8%+65.6%+279.5%+458.5%+136.3%
1 vCPU / 1024 MB+8.0%+17.9%+206.8%+280.4%+61.4%
2 vCPU / 1024 MB+44.3%+33.8%+140.6%+206.9%+11.0%

Deno closes the gap noticeably on dynamic API routes once it has 1 GB of RAM to play with (its V8 instance gets room for code-cache + JIT tiering), but prerendered SSG and RSC routes stay 2-4x ahead because that path bypasses JavaScript entirely on Nexide.

What this means honestly

  • Static / RSC routes are 3-5x faster. This is where the static prerender hot path bypasses the V8 pool entirely.
  • Route handlers are ~60-80% faster on a single core, ~140% faster on two. V8 dispatch overhead is the dominant cost; Rust does not magically make your JS faster, it makes the path to your JS shorter.
  • /_next/image is 2x faster at one third of the CPU. Image optimization in Node/Deno goes through the JS image-optimizer + sharp; in Nexide it is a fully native Rust pipeline (image + webp + imagequant) with a bounded in-memory LRU in front of the on-disk cache. CPU stays low even under saturation, leaving headroom for the rest of the app. The advantage shrinks at 2 vCPU because the JS path can finally use the second core.
  • Memory cost is real. Nexide uses 2-4x the memory of Node.js because it pre-warms a pool of V8 isolates. This is the central trade-off.
  • CPU utilisation is broadly comparable on JS routes. No magic; the wins come from parallelism in the pool and a leaner request path, not from running V8 less.

Reproduce locally:

cargo run --release -p nexide-bench -- docker-suite \
--routes api-ping,api-time,ssg-about,rsc-about,next-image \
--runtimes nexide,node,deno \
--presets 1cpu-512mb,2cpu-1024mb \
--duration 30s --connections 64

Quick start

Requirements: Rust 1.85+ (for edition 2024), a built Next.js standalone bundle.

# build the runtime
cargo build --release
# build the next-fixture e2e app
pnpm --dir e2e/next-fixture install
pnpm --dir e2e/next-fixture build
# serve it
./target/release/nexide start e2e/next-fixture --port 3000
# probe it
curl -s http://127.0.0.1:3000/api/ping
# {"ok":true,"runtime":"nexide","method":"GET"}

Architecture

 ┌──────────────────────────────┐
incoming request ──► │ Axum / Tower HTTP server │
│ (crates/nexide/src/server) │
└──────────────┬───────────────┘
│
┌─────────────────────────────────┼─────────────────────────────────┐
│ │ │
static / prerender /_next/image dynamic dispatch
(Rust, no V8) (Rust, no V8) │
ssg, rsc, decode → resize → encode │
_next/static (immutable), + in-mem LRU + on-disk cache │
public (crates/nexide/src/image) │
▼
┌────────────────────────────────┐
│ EngineDispatcher │
│ (crates/nexide/src/dispatch) │
└────────────────┬───────────────┘
│
▼
┌────────────────────────────────┐
│ IsolatePool: hot V8 isolates │
│ (crates/nexide/src/pool) │
│ + per-isolate event pump │
│ + RecyclePolicy (heap/req) │
└────────────────┬───────────────┘
│
▼
┌────────────────────────────────┐
│ V8 isolate (raw rusty_v8) │
│ + Node compat polyfills (JS) │
│ + Rust ops bridge (op_*) │
│ (crates/nexide/src/engine, │
│ crates/nexide/runtime) │
└────────────────────────────────┘

The runtime does not depend on deno_core, deno_runtime, node-api, or napi. V8 is embedded directly via the v8 crate (v147), which gives full control over isolate lifecycle, microtask checkpoints, heap limits, and the op call ABI.

Native /_next/image optimizer

Image optimization is the second hot-path that bypasses V8 entirely. Nexide ships a Next.js-compatible /_next/image implementation in crates/nexide/src/image/ that mirrors Next 16.2.4 behaviour:

  • Validation — same images: config from next.config.mjs (deviceSizes, imageSizes, formats, qualities, domains, remotePatterns, localPatterns, dangerouslyAllowSVG, contentDispositionType, minimumCacheTTL).
  • Source resolution — local files under public/ and .next/static/ via a canonicalising FsResolver, plus remote fetch with allow-list matching (picomatch-style globs).
  • Pipeline — decode (PNG/JPEG/WebP/GIF/AVIF/SVG), resize (Lanczos3), encode WebP/AVIF/JPEG/PNG with quality-aware quantisation (imagequant for PNG palette, webp for WebP, ravif for AVIF).
  • Caching — bounded in-memory LRU (256 entries / 64 MB, zero-copy bytes::Bytes) in front of an SHA-256-keyed on-disk cache that respects the upstream Cache-Control and minimumCacheTTL. Repeat requests are answered with x-nextjs-cache: HIT without ever resolving the source.
  • /_next/static immutable — Next.js's hashed asset folder is served with Cache-Control: public, max-age=31536000, immutable so browsers and CDNs can cache forever.

The benchmark above (_next/image row) measures exactly this path against Next.js's own JS-based optimizer running in Node 22 / Deno 2.

Repository layout

nexide/
├── crates/
│ ├── nexide/ ← the runtime crate
│ │ ├── src/
│ │ │ ├── server/ ← Axum HTTP shield, static asset routes,
│ │ │ │ prerender hot path, accept loop
│ │ │ ├── engine/ ← V8 engine, CJS resolver, isolate trait
│ │ │ ├── ops/ ← Rust op_* implementations exposed to JS
│ │ │ │ (fs, net, dns, tls, http_client, process,
│ │ │ │ zlib, log, request/response, queue, …)
│ │ │ ├── pool/ ← isolate pool, worker, event pump strategies,
│ │ │ │ recycle policies, mem sampler
│ │ │ ├── dispatch/ ← EngineDispatcher trait + errors
│ │ │ ├── cli.rs ← `nexide start|bench|inspect` subcommands
│ │ │ └── lib.rs ← public crate surface
│ │ ├── runtime/
│ │ │ └── polyfills/ ← JS shipped into every isolate
│ │ │ ├── node/ ← node:* modules (path, fs, http, crypto, …)
│ │ │ ├── async_local_storage.js
│ │ │ ├── cjs_loader.js
│ │ │ ├── http_bridge.js
│ │ │ ├── nexide_bridge.js
│ │ │ └── web_apis.js
│ │ └── tests/ ← integration tests (266 total)
│ ├── nexide-bench/ ← bench harness: local + docker-suite
│ └── nexide-e2e/ ← end-to-end tests against real Next.js
├── e2e/
│ ├── next-fixture/ ← Next.js 16 reference app used in tests + bench
│ └── prisma-sqlite/ ← Prisma library engine (N-API) + SQLite fixture
├── docs/ ← design docs and historic task breakdowns
├── DESCRIPTION.md ← original feasibility study
├── README.md ← this file
├── LICENSE-MIT ← dual licensed
├── LICENSE-APACHE ← dual licensed
├── SECURITY.md ← responsible disclosure policy
├── CONTRIBUTING.md ← how to contribute
└── CODE_OF_CONDUCT.md ← contributor covenant

Node.js compatibility

Nexide ships its own implementations of the Node.js surface that Next.js actually uses. Both require('node:foo') and require('foo') resolve to the same instance.

ModuleStatusNotes
pathfullPOSIX + Win32, picked from process.platform
path/posix / path/win32fullalways-platform variants (Node parity)
urlfullURL, URLSearchParams, legacy parse/format
querystringfullparse / stringify / escape / unescape
punycodefullRFC 3492 (vendored upstream punycode.js v2.1.0)
utilpragmaticformat, inspect, promisify, callbackify
util/typesfullre-export of util.types
assert / assert/strictfullstrict-equality semantics by default
osfulllive + injectable backend (OsInfoSource)
eventsfullEventEmitter + once / on static helpers
buffer / BufferfullUTF-8 / base64 / hex / latin1 / ascii / ucs2
streamcoreReadable / Writable / Duplex / Transform
stream/webfullre-export of WHATWG Streams from globalThis
stream/promisesfullpromise-returning pipeline / finished
stream/consumersfullbuffer / text / json / arrayBuffer / blob
string_decoderfullUTF-8 / UTF-16LE / Latin-1 multi-chunk safe
fs + fs/promisessandboxedpath sandbox; only configured roots are admitted
zlibfullgzip, deflate, brotli (sync + async wrappers)
cryptocoresha1/256/512, md5, HMAC, AES-256-GCM, randomUUID
http / httpsserver-sideenough for Next.js standalone server entrypoint
http2stubloads + constants; createServer/connect throw
net / tlsclient-sideenough for outbound fetch and DB drivers
dns / dns/promisesfulluses Tokio's resolver via Rust ops
diagnostics_channelfullChannel + TracingChannel (undici, OTel, APMs)
readline / readline/promisesfunctionalline buffering over any Readable; no TTY UI
child_processcorespawn / exec with stdio piping
worker_threadsnot supportedthrows on new Worker(...)
vmfullreal v8::Context per sandbox; not a security boundary
async_hooksALS onlyAsyncLocalStorage works; full hooks do not
perf_hookscoremonotonic clock, basic marks
timers / timers/promisesfullbacked by Tokio
inspector / tty / v8 / module / constantscoreenough surface for transitive deps
GlobalStatusNotes
process.envwhitelistedNEXT_*, NODE_*, NEXT_PUBLIC_* + a few extras
process.cwd/exit/hrtime/nextTickfullexit is recorded in OpState, never aborts host
process.platform/.archfullNode-compatible strings
Bufferfullglobal, identical to require('buffer').Buffer
setTimeout / setInterval / setImmediatefulltimers backed by Tokio
queueMicrotaskfullV8 native
fetch, Headers, Request, ResponsefullWHATWG Fetch
ReadableStream, WritableStream, TransformStreamfullWHATWG Streams
TextEncoder / TextDecoder, URL, URLSearchParamsfullWHATWG
crypto.subtle / crypto.getRandomValues / crypto.randomUUIDfullWebCrypto subset

Known limitations

Nexide is a V8-only Next.js runtime; some Node platform surfaces are intentionally absent or partial. The full list — N-API/native addons, http2, worker threads, inspector, ESM at runtime, source maps, corporate proxies, log rotation, etc. — lives in docs/known-limitations.md.

If you hit something that isn't on that list, open an issue with the exact MODULE_NOT_FOUND / runtime error and a minimal repro.

Docker

Pre-built multi-arch images (linux/amd64, linux/arm64) are published to the GitHub Container Registry on every tagged release:

docker pull ghcr.io/sonquer/nexide:latest
# or pin a version:
docker pull ghcr.io/sonquer/nexide:0.1.0

The image is Alpine-based (alpine:3.20 runtime) with gcompat providing the glibc shim needed by V8. The binary listens on 0.0.0.0:3000 by default and serves the Next.js standalone bundle mounted at /app.

# build your Next.js app first (produces .next/standalone)
pnpm --dir e2e/next-fixture install
pnpm --dir e2e/next-fixture build
# run nexide against it
docker run --rm -p 3000:3000 \
-v "$(pwd)/e2e/next-fixture:/app:ro" \
ghcr.io/sonquer/nexide:latest

To build the image locally:

docker build -t nexide:dev .
docker run --rm -p 3000:3000 -v "$(pwd)/e2e/next-fixture:/app:ro" nexide:dev

Migrating from Node.js or Deno

Nexide uses Next.js's standard output: 'standalone' build artefact, so the build pipeline does not change. Only the runtime stage of your Dockerfile needs swapping. Make sure your next.config.js has:

module.exports={output: 'standalone',};

From Node.js

Before (typical Node.js Dockerfile):

FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

After (Nexide):

FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM ghcr.io/sonquer/nexide:latest AS runtime
WORKDIR /app
COPY --from=builder --chown=nexide:nexide /app/.next/standalone ./
COPY --from=builder --chown=nexide:nexide /app/.next/static ./.next/static
COPY --from=builder --chown=nexide:nexide /app/public ./public
EXPOSE 3000
# ENTRYPOINT ["/usr/local/bin/nexide"] and CMD ["start", "/app"] are# inherited from the base image. Override CMD if you need to customise# bind address or port (although usually setting HOSTNAME/PORT env vars# is cleaner):# CMD ["start", "/app", "--hostname", "0.0.0.0", "--port", "8080"]

What changed:

  • Runtime image: node:22-alpineghcr.io/sonquer/nexide:latest.
  • CMD ["node", "server.js"] is replaced by the inherited Nexide entrypoint nexide start /app. There is no node process - Nexide loads the standalone bundle directly into V8.
  • --chown=nexide:nexide because the Nexide image runs as the non-root nexide user (uid 10001).

From Deno

Before (typical Deno Dockerfile):

FROM denoland/deno:2.0.0 AS builder
WORKDIR /app
COPY . .
RUN deno task build
FROM denoland/deno:2.0.0 AS runtime
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["deno", "run", "-A", "--unstable-node-modules-dir", "server.js"]

After (Nexide):

FROM denoland/deno:2.0.0 AS builder
WORKDIR /app
COPY . .
RUN deno task build
FROM ghcr.io/sonquer/nexide:latest AS runtime
WORKDIR /app
COPY --from=builder --chown=nexide:nexide /app/.next/standalone ./
COPY --from=builder --chown=nexide:nexide /app/.next/static ./.next/static
COPY --from=builder --chown=nexide:nexide /app/public ./public
EXPOSE 3000
# ENTRYPOINT ["/usr/local/bin/nexide"] and CMD ["start", "/app"] are# inherited from the base image.

What changed:

  • Runtime image: denoland/deno:2.0.0ghcr.io/sonquer/nexide:latest.
  • CMD ["deno", "run", "-A", "--unstable-node-modules-dir", "server.js"] is replaced by the inherited Nexide entrypoint nexide start /app. No -A permission flags, no --unstable-* flags, no node_modules resolver toggles - Nexide loads the Next.js standalone bundle directly into V8 via its native HTTP server.
  • --chown=nexide:nexide because Nexide runs as the non-root nexide user (uid 10001).

Nexide does not care which runtime produced the bundle, only that the resulting .next/standalone directory is laid out correctly.

Pinning to a specific Nexide version

Production images should pin a full version, not latest:

FROM ghcr.io/sonquer/nexide:0.1.0 AS runtime

Nexide is pre-1.0 and the runtime ABI is unstable. A point release can change which Node.js compatibility surface ships, which heap defaults are applied, and which environment variables are read. Pin, then upgrade deliberately.

Environment variables

VariableDefaultEffect
HOSTNAME127.0.0.1 (0.0.0.0 in the published image)Bind address for nexide start. Override to 0.0.0.0 when running in a container.
PORT3000TCP port for nexide start.
RUST_LOGinfoStandard tracing-subscriber filter syntax.
NODE_ENVinheritedForwarded into process.env; Next.js reads it.
NEXT_*inheritedWhitelisted into process.env for Next.js.
NEXT_PUBLIC_*inheritedWhitelisted for client-side env injection.

The same flags can be passed on the CLI: nexide start /app --hostname 0.0.0.0 --port 8080.

Building and testing

# full check, lint, test
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-targets
# release build
cargo build --release
# subcommands
./target/release/nexide start <next-app-dir>
./target/release/nexide bench <next-app-dir>
./target/release/nexide inspect <next-app-dir>

The workspace is configured with strict lints: dead_code, missing_docs, unused_imports, unused_variables, unused_mut, unreachable_code, unreachable_pub and unsafe_op_in_unsafe_fn are all deny. The bar for landing a PR is "the workspace builds with zero warnings".

Releasing

Releases are cut by pushing a semver tag to main. Everything else is automated by .github/workflows/release.yml.

  1. Make sure main is green (CI passing) and the working tree is clean.

  2. Bump the workspace version in Cargo.toml:

    [workspace.package]
    version = "0.1.0"
  3. Run cargo build --release once locally to refresh Cargo.lock, then commit:

    git add Cargo.toml Cargo.lock
    git commit -m "release: 0.1.0"
    git push origin main
  4. Tag and push the tag:

    git tag -a v0.1.0 -m "v0.1.0"
    git push origin v0.1.0
  5. The Release workflow builds a multi-arch Docker image and pushes it to ghcr.io/sonquer/nexide with the following tags:

    • 0.1.0 (full version)
    • 0.1 (major.minor, only for stable tags)
    • 0 (major, only for stable tags)
    • latest (only when the tag does not contain -, i.e. not a prerelease)

    It then creates a GitHub Release with auto-generated notes from the commit history since the previous tag.

Prereleases

For prereleases use a hyphen-suffixed tag, e.g. v0.2.0-rc.1. The workflow detects this and:

  • Skips the latest tag.
  • Marks the GitHub Release as a prerelease.

Yanking a release

Container images on GHCR can be deleted via the package settings page or the GitHub CLI:

gh api -X DELETE /user/packages/container/nexide/versions/<version-id>

GitHub Releases can be deleted from the Releases UI. Prefer publishing a patch release over yanking unless the artefact is actively harmful.

Roadmap

Short term, in rough priority order:

  1. Tune the isolate pump strategy on small containers (1-2 vCPU) where coalesced wakeups currently lose to serial dispatch on tail latency.
  2. Reduce baseline memory: each warm isolate carries ~50 MB of V8 state; investigate startup snapshots once they pay off in a Hot Isolate model.
  3. Tighten the node:http outbound surface so keep-alive agents and streamed bodies match Node behaviour byte-for-byte.
  4. Stabilise the embed API (BootContext, FsHandle, dispatcher trait).

Explicit non-goals:

  • Replacing Node.js for general workloads. Use Node, Deno or Bun.
  • A plugin / native addon ABI. There is no node-api, no NAPI, no .node modules. If a Next.js dependency needs one, it has to be ported or replaced.
  • Supporting old Next.js versions. Tracking current stable is enough work.

Contributing

See CONTRIBUTING.md. TL;DR: open an issue first if the change is non-trivial, run cargo fmt && cargo clippy && cargo test before pushing, follow the existing module conventions (no inline comments inside function bodies, doc comments on every public item).

By participating you agree to abide by CODE_OF_CONDUCT.md.

Security

Security issues should not be filed as public GitHub issues. See SECURITY.md for the responsible disclosure process.

License

Dual licensed under either of:

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

Acknowledgements

Nexide stands on the shoulders of V8, rusty_v8 (the same V8 binding the Deno project maintains), Tokio, Axum, Hyper, and the WHATWG / Node.js spec bodies whose work made any of this tractable.

Star History

Star History Chart

Contributors Hall of Fame

Nexide contributors

About

Native Rust runtime for Next.js. Replaces Node.js with a pool of raw V8 isolates behind Axum/Tower. Faster cold start, ~3-5x higher RPS on static/RSC routes.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages