Skip to main content
Pylon ships a native SSR runtime, so you can serve dynamic pages without adding Next.js. Add a file under app/ and it becomes a route. React renders on the server, hydrates on the client, and <Link> makes navigation instant.
There is no next.config.js, no separate apps/web, and 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 connection, 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 do not show up in the URL.
Page components receive:
auth is resolved from the session cookie. Anonymous requests get user_id: null. Your page needs no extra call to get the session. The SSR runtime provides it directly.

Layouts

app/layout.tsx wraps every page below it. Nested layouts compose:
Layouts survive client-side navigation. When the user clicks a <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 all persist.

How it works

  1. A request arrives. Pylon’s router matches the URL against ssr_routes, which is auto-discovered from app/.
  2. Pylon dispatches render_route to the Bun runtime over its NDJSON pipe.
  3. The Bun adapter import()s the page and layout modules, calls react-dom/server.renderToReadableStream, and pipes the chunks back to Rust.
  4. 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.
  5. After React’s stream ends, Pylon appends <script id="__PYLON_DATA__"> with the hydration payload and a per-route <script type="module">.
  6. The browser executes the module, which dispatches to hydrateRoot and installs the click and popstate handlers for <Link>.
The whole pipeline streams. The first byte reaches the client as soon as React emits its first chunk, not after the whole page finishes building.

Loading data during the render

Pages read the database mid-render through the serverData prop. This is a read-only, policy-gated handle, awaited with React 19’s use() inside <Suspense>. Resolved values replay through the hydration payload, so the client never re-fetches. Writes are rejected: a GET render must not change data. When the data you need is shaped by a query function, rather than raw entity reads (a gated public projection that filters unpublished rows or strips private fields), call the function itself:
serverData.fn(name, args) runs the registered query with the page’s own auth context (anonymous on a public page), so the query stays the single source of truth for SSR and the client. It accepts query functions only. Mutations and actions are rejected. Calling it on a signed-in request opts the render out of shared SSR caching, since the query may read ctx.auth. Anonymous renders stay cacheable.

Code splitting

Per route

Each page becomes its own entry chunk. It shares one big chunk with React, react-dom, and Pylon’s runtime (about 98KB gzipped). When the user lands on /hello, the browser downloads the shared chunk and the /hello entry. When they navigate to /about, only the /about entry downloads. The shared chunk is already cached with Cache-Control: immutable. Add a 50th route, and the shared chunk stays the same size. The new page only contributes its own entry. The bundle does not grow linearly with route count. The bundler emits a manifest.json that lists every route’s entry file and the chunks it depends on. The SSR head adapter reads it on each render and emits the right preload tags.

Within a route

A route’s entry is only as small as what the page imports. A page that pulls in a rich-text editor puts the whole editor in its entry. One real app measured a single route at 1MB of brotli-compressed code for a composer most visitors never opened. Because <Link> warms sibling routes on sight, that weight lands on the first load of every page in the section. dynamic() moves a component behind a real import(): its own chunk, deliberately left out of the route’s preload set, fetched only when something renders it.
Call it at the module level, never inside a render. A dynamic() call per render creates a new component type each time and remounts the subtree. ref reaches the loaded component, so a deferred forwardRef editor whose save path calls ref.current.export() keeps working.
ssr defaults to false. This is the opposite of Next.js, and it is deliberate. A Pylon page hydrates once after the whole document has streamed, so the server HTML and the first client render must be identical. ssr: false guarantees this by rendering the fallback in both places, which makes a hydration mismatch impossible. It is also the only mode that removes bytes from the first load. An ssr: true component must be in the client bundle before hydration, so it saves nothing. It exists for organizing code, not for reducing weight.
Do not confuse this with the route-segment export export const dynamic = "force-static" | "force-dynamic", which is a cache directive. Both uses of the word dynamic come from Next.js, but they mean different things.

Deferring one component is not enough on its own

dynamic() only removes weight if nothing else in the route imports the same library statically. One sibling component is enough to undo it:
The bundler is right to preload it at that point, because the route does import it eagerly. The symptom is a heavy chunk sitting in the route’s preload set, which looks exactly like a bundler that ignored dynamic(). A real app lost its entire savings this way, to thirteen imported constants used by a sidebar rendered next to the editor. To check, look at how your route’s entry references the chunk:
import("…") with a paren is deferred and will not be preloaded. import"…" without one is eager. Grep your own source for that library and defer the other importer too.

Current limits

  • Server Actions / RSC: Pylon serves dynamic pages but does not ship React Server Components or the "use server" action model. Server functions (query / mutation / action) and the typed client cover the same ground. Call them from client components instead of inlining server code into the render tree.
Special files (loading.tsx, error.tsx, not-found.tsx), <Suspense> streaming (opt in with a loading.tsx boundary or export const streaming = true), and metadata (metadata / generateMetadata, and dynamic opengraph-image.tsx) have all shipped since this list was first written. loading.tsx works in both directions. It is the Suspense fallback the SSR stream flushes first on a document load, and it is what the client router paints over the page area when a <Link> navigation runs longer than about 100ms. Faster navigations swap straight to content, so it never flashes. It resolves by nearest ancestor, and route groups are transparent. A loading.tsx in app/(dash)/ covers routes at /. error.tsx and not-found.tsx follow the same rule.

Data files

Four root-level files under app/ serve generated documents instead of pages. Each exports a default function, sync or async, so it can enumerate routes from the database:
  • app/sitemap.ts/sitemap.xml
  • app/robots.ts/robots.txt
  • app/llms.ts/llms.txt
  • app/**/opengraph-image.tsx → a PNG for that route
Every page also answers as markdown when a client asks for it. See Agent-readable pages.

Try it

Open http://localhost:4321/. It serves three pages, all from Pylon, with no Next.js anywhere.