Building & deploying
A JSlop app builds in two passes and serves with a small Node adapter. This page walks the full production flow.
The build
pnpm build
# expands to: vite build && vite build --ssr
Pass 1 — vite build:
- Compiles every
.jslopfile via@jslop/compiler. - Emits the client bundle to
dist/client/with hashed filenames inassets/. - Emits
dist/client/.vite/manifest.jsonso the server entry can look up which asset URL corresponds to the client entry. - Bundles any CSS you imported (and Tailwind, if enabled) into hashed CSS files alongside.
Pass 2 — vite build --ssr:
- Bundles the server entry to
dist/server/entry-server.js. - Inlines
@jslop/server,@jslop/router, and your route components into a single self-contained module. - Exports a
render(url, opts?) → { status, html, headers }function.
After both passes, your dist/ looks like:
dist/
├── client/
│ ├── .vite/manifest.json
│ └── assets/
│ ├── client-<hash>.js
│ └── client-<hash>.css (if you imported any CSS)
└── server/
└── entry-server.js
Serving the build
Use @jslop/node-adapter for a tiny Node HTTP server:
// serve.mjs
import { createServer } from "@jslop/node-adapter";
import { render } from "./dist/server/entry-server.js";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const port = Number(process.env.PORT ?? 3000);
const server = createServer({
render,
clientDir: resolve(here, "dist/client"),
});
server.listen(port, () => {
console.log(`listening on http://localhost:${port}`);
});
pnpm serve
What the adapter does:
- Paths that look like asset URLs (
/assets/client-abc.js) → served as static files fromclientDir, withcache-control: public, max-age=31536000, immutable(safe because Vite hashes their names). - Everything else → passed to
render(url), which returns{ status, html, headers }. - The SSR entry reads
dist/client/.vite/manifest.jsononce and caches it, so the rendered HTML always references the correct hashed asset URLs.
Custom adapters
render(url, opts?) is request-agnostic — it takes a URL string and returns a plain object. That means you can drop it into any host:
// Bun
import { render } from "./dist/server/entry-server.js";
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname.startsWith("/assets/")) return new Response(/* ... */);
const { status, html, headers } = await render(url.pathname + url.search);
return new Response(html, { status, headers });
},
});
Cloudflare Workers / Deno / Lambda follow the same pattern — feed url in, send html back.
Note
Right now
@jslop/node-adapter is the only adapter shipped. Bun, Workers, Deno, and edge adapters are on the roadmap. The render contract is intentionally minimal so writing one is a small task.Streaming SSR
Warning
Today,
render() produces the full HTML as a single string before responding. Streaming SSR is not yet implemented.Static site generation (SSG)
Warning
A “render every route at build time” mode is not yet implemented. You can hack it with a script that calls
render() for each known URL and writes the result to disk, but there’s no first-class command for it.Environment variables
Vite’s standard rules apply. Use import.meta.env.VITE_* in any code that needs to run in the browser. Server-only env vars are just process.env.X inside serve.mjs or your adapter glue.
See also
- Project structure — where
serve.mjsandvite.config.mjssit. - SSR & resumability — what the SSR pass produces and how the client picks up.
- Internals: architecture — what
@jslop/vitedoes with the build hooks.