app/, get a route. React renders on the server, hydrates on the client, and <Link> makes navigation instant.
next.config.js, no separate apps/web, no proxy in front. pylon dev (or the production binary) serves the API, the SSR pages, the JS bundles, the optimized images, and the WebSocket all on one port.
File-based routes
app/<path>/page.tsx becomes GET /<path>. Use [slug] for dynamic segments and (group) for grouping folders that don’t show up in the URL.
auth is resolved from the session cookie. Anonymous requests get user_id: null. There is no extra “get the session” call from your page — the SSR runtime hands it in.
Layouts
app/layout.tsx wraps every page below it. Nested layouts compose:
<Link> from /dashboard/projects to /dashboard/settings, the DashboardLayout instance stays mounted and only its children swap. Sidebar scroll position, open menus, and form state persist.
How it works
- Request arrives → Pylon’s router matches the URL against
ssr_routes(auto-discovered fromapp/). - Pylon dispatches
render_routeto the Bun runtime over its NDJSON pipe. - The Bun adapter
import()s the page + layout modules, callsreact-dom/server.renderToReadableStream, and pipes chunks back to Rust. - Rust streams them over HTTP chunked-transfer-encoding, splicing
<link rel="stylesheet">and<link rel="modulepreload">into<head>from the build manifest as the stream flows past. - After React’s stream ends, Pylon appends
<script id="__PYLON_DATA__">with the hydration payload and a per-route<script type="module">. - Browser executes the module, which dispatches to
hydrateRootand installs the click + popstate handlers for<Link>.
Per-route code splitting
Each page becomes its own entry chunk (~500 bytes gzipped) sharing one big chunk with React + react-dom + Pylon’s runtime (~98KB gz). When the user lands on/hello, the browser downloads the shared chunk + the /hello entry. When they navigate to /about, only the /about entry hits the wire (a couple hundred bytes); the shared chunk is already cached with Cache-Control: immutable.
Add a 50th route → the shared chunk stays the same size and the new page contributes its own tiny entry. The bundle does not grow linearly with route count.
The bundler emits a manifest.json listing every route’s entry file + the chunks it depends on. The SSR head adapter reads it on each render and emits the right preload tags.
What’s not here yet
loading.tsx/error.tsx/not-found.tsx— special files like Next.js’s app router. Phase 3.- Server Actions / RSC — Pylon serves dynamic pages but doesn’t ship React Server Components today. Functions + the typed client handle the same use cases.
- Streaming with
<Suspense>— the stream itself works (React emits multiple chunks), but boundary-level suspense fallbacks aren’t yet wired through the manifest.
Try it
http://localhost:4321/ — three pages, all served by Pylon, no Next.js anywhere.