Deploying#
A Janux app in production is a Request → Response function plus dist/client. Which runtime calls that function is a build-time choice — an adapter — and nothing in src/ depends on it.
Adapters#
| Target | Adapter | Command | WebSockets | Streaming | Filesystem | Schedules |
|---|---|---|---|---|---|---|
| Bun | built in | bun run build && bun run start |
✅ | ✅ | ✅ | ✅ in-process |
| Node 24+ | @janux/node |
bun run build && bunx janux-node → node build/index.js |
✅ | ✅ | ✅ | ✅ in-process |
| Vercel | @janux/vercel |
bun run build && bunx janux-vercel |
❌ serverless | ✅ | ✅ /tmp |
⏱ platform cron |
| Any static host | output: "static" |
bun run build → upload dist/client |
❌ | — | ❌ | ❌ |
| Cloudflare, Netlify, Deno | not shipped | write one | — | — | — | — |
Bun and Node are full parity: same app, same features, different node_modules. Vercel trades WebSockets for a CDN and zero servers. output: "static" gives up the server entirely.
The schedules column is the one that is not a simple yes/no. A persistent process ticks src/schedules/ itself; a serverless target has no process to tick with, so the platform's cron calls /_janux/schedules/tick instead (Vercel with a GET) and JANUX_CRON_SECRET — or Vercel's own CRON_SECRET — gates it. Same schedules, same store, different trigger — the adapter declares which, and the difference is not hidden from you.
A ❌ is not something you discover in production: an adapter declares what it supports, and janux build prints the app features a missing capability disables.
Node#
The one people ask for first, because it means no Bun on the box:
bun add @janux/node # the adapter is a dependency of the app, like @janux/vercel
bun run build # the client bundle — Vite, as always
bunx janux-node # → build/
node build/index.js # PORT=3000 by defaultbuild/ is self-contained — copy that directory anywhere with Node 24+ and run it. There is no install step, because there is nothing left to resolve:
build/index.js # the launcher
build/.janux/index.js # the bundled server: every app module inlined
build/dist/client/ # client bundle + stylesheet, served with cache headers and brotli
build/src/ # the app's source — the router reads it to learn which URLs exist
build/package.json # {"type":"module"}FROM node:24-slim
WORKDIR /app
COPY build ./
ENV PORT=3000
CMD ["node", "index.js"]Apps that read their own files at runtime pass them along: bunx janux-node --include content.
src/ws.ts works here and nowhere else among the shipped adapters: the ws implementation is bundled in, so a WebSocket app stays as install-free as any other.
Working example: examples/with-node-adapter.
Bun#
The default, and still the shortest path: janux start serves dist/client and the app from one process, WebSockets included. One stage — install, bun run build, bun run start. The full Dockerfile, with the .dockerignore it needs, a healthcheck that works without curl, and measured image sizes, lives in Docker. It runs as-is on Fly.io, Railway, Render, a VPS — anything that runs a container.
Vercel#
Vercel has its own page: the adapter writes a Build Output API directory, so the platform builds nothing and traces nothing.
Something else#
The adapter API is public and documented: Writing an adapter is enough to target Cloudflare, Netlify, Deno or your own infrastructure without reading Janux's source. The runtime contract is one function, and every platform above already speaks it.
Environment checklist#
| Variable | Required? |
|---|---|
JANUX_MODEL or one provider API key |
Only if you want the copilot live (without it the agent answers a setup card — the app itself works) |
PORT |
Optional, defaults to 3000 |
HOST |
Optional; @janux/node binds every interface without it |
| Your own secrets (DB urls, etc.) | Read them in ctxFor / api modules as usual |
What to check after deploy#
curl -s https://your.app/ # SSR HTML; static pages: zero <script>
curl -s https://your.app/_janux/manifest # the agent surface is up
curl -s -X POST https://your.app/_janux/agent \
-H 'content-type: application/json' -d '{"messages":[]}' # text/setup, not 500Static export (output: "static")#
For sites whose pages don't depend on per-request state — documentation, marketing, blogs — you can skip the server entirely:
// janux.config.ts
import { defineConfig } from 'janux';
export default defineConfig({ output: 'static' });Now janux build also prerenders every page into dist/client:
dist/client/index.html # /
dist/client/docs/guide/overview/index.html
dist/client/docs/guide/overview.md # markdown projection of each page (`/` → `.md`)
dist/client/404.html # src/routes/_404.tsx, when the app has one
dist/client/llms.txt # agent index, when llmsTxt is configured
dist/client/sitemap.xml, robots.txt # when siteUrl is configured
dist/client/rss.xml # when siteUrl is set and the app has src/feed.ts
dist/client/client.js, styles.css, ... # islands still hydrate on interactionUpload dist/client to any static host (GitHub Pages, Netlify, Cloudflare Pages, an S3 bucket) — no Bun, no Node, no server.
Dynamic routes need staticParams#
A file like routes/docs/[section]/[slug].tsx matches infinitely many URLs, so the build can't know which pages exist. Export staticParams to enumerate them — an array of param records, or a sync/async function returning one (resolved like meta):
// routes/docs/[section]/[slug].tsx
export function staticParams() {
return docIndex().map(({ section, slug }) => ({ section, slug }));
}
export default function DocPage({ params }) { ... }Every record becomes a prerendered page ({ section: 'guide', slug: 'overview' } → /docs/guide/overview). Dynamic routes without staticParams are skipped with a build warning. The export also improves server apps: llms.txt lists the concrete pages instead of the raw /docs/[section]/[slug] pattern, so agents can navigate directly.
What you give up#
A static export is HTML + islands only. Everything under /_janux/* needs the server: api() endpoints, the manifest, proposals/approvals and the copilot. If your app uses those, ship a server instead — Bun, Node or Vercel above — output: "static" is for sites, not apps.
Scaling notes#
- The server is stateless per request except pending agent proposals (in-memory, capped at 100). Behind a load balancer, use sticky sessions for the copilot flow — or approve on the same page session, which is the normal UX anyway.
- Static pages are aggressively cacheable: they're plain HTML with no per-user state. Put a CDN in front and cache everything that isn't
/_janux/*. dist/clientassets are immutable — long cache lifetimes are safe.