Leptos 0.8.22SSR on Axum, a TypeScript client, no WASM
How this storefront is built
Leptos renders every page on the server. A small TypeScript client changes state in the browser and asks the server only for data it doesn't have. Each card below says what a piece does and why, runs it live, links to where the shop uses it, and quotes the code.
- 16cards, each with a live demo
- 0bytes of WebAssembly
- 0.9 KBof JavaScript, raw, minified
- Nileshop, picked from the Host header
- 1BrowserAsks for /showcase with its Host and cookies.
GET /showcase - 2AxumPicks the shop from the Host, serves /js, /media and the /__prefs and /__sim helpers, and compresses.
tower-http · moka - 3Leptos SSRRenders the route in order into one complete document; status and headers go first.
SsrMode::InOrder - 4Page data#app-config and #app-strings: the shop, the language, the endpoints and every UI string the client needs.
application/json - 5TypeScriptOne module boots: read the page, sync the stores, mount the features, start the router.
/js/app-….js
Leptos on the server
Every page is a Leptos component rendered on the server into one complete HTML document. No Rust runs in the browser.
In-order SSR on every page
Leptos on the serverEvery route is rendered with SsrMode::InOrder. Leptos renders the page top to bottom and streams it in that order, waiting at each <Suspense> for its data, so every byte that leaves the server is final HTML. No script has to move anything into place.
Why it matters herePages must work without JavaScript, and the TypeScript router swaps whole server-rendered pages: both need complete HTML. The status code and cache headers are set before the first byte, so a 404 is a real 404.
Live demo
/InOrder/c/:slugInOrder/searchInOrder/p/:slugInOrder/cartInOrder/cart/editInOrder/checkoutInOrder/ordersInOrder/orders/:idInOrder/showcaseInOrder/uiInOrder/core-scratchInOrder/showcase/streamOutOfOrderRead from crates/app/src/lib.rs when the server was built.
- This page's first byte
- —
- Last byte
- —
- HTML, as sent and unpacked
- —
- Script needed to see the page
- None
Timings are the browser's own (Navigation Timing) for the document this tab loaded first.
Out-of-order streaming, on its own route
Leptos on the serverLeptos can also send the page's frame first and then each <Suspense> the moment its data resolves. Each section arrives as a <template> plus a small inline script that moves it into place. The demo route /showcase/stream is the only page here that streams this way.
Why it matters hereIt gives the fastest first paint, but sections only land with JavaScript on, and a router that fetches the page as HTML gets it only once the slowest section is done. That's why the demo links carry data-router="off": only a full page load lets you watch the stream.
Live demo
Simulated latency is off: the blocks would all arrive at once. Pick a latency first.
Open the streaming demoWith the deals failing
Both open as a full page load (data-router="off"). The client router would fetch the page as HTML and get it only after the slowest block, so you'd see nothing stream.
Server functions are the data API
Leptos on the server#[server(endpoint = "cache/stats", input = GetUrl)] turns an async Rust function into an HTTP endpoint with a fixed URL. Server components call it like any function. The TypeScript client fetches the URL and gets JSON back, with money already formatted.
Why it matters hereOne Rust function serves the server's own reads and the client's fetches, so there's no second REST layer to keep in step. Fixed names keep URLs the same from build to build (default names carry a hash), so the client and the CDN can rely on them.
Live demo
- Status
- —
- Time
- —
- Cache-Control
- —
{ … }The endpoints this page's client may call, from #app-config:
GET /api/cache/statsPOST /api/cache/bump
Error boundaries, one per section
Leptos on the serverEach section that reads data has its own <ErrorBoundary>. When a read fails, that section says what failed and offers Retry, and the rest of the page renders normally. A failure that is known before the first byte becomes a full error page with a real 500 or 503.
Why it matters hereA slow or broken recommendations service shouldn't take the product page down with it. ?fail=<widget> breaks one read on purpose, so every error state can be checked.
Live demo
home_rail(Deals)home_rail(Bestsellers)
Coca-Cola OriginalEGP 29.00
Farm Brown EggsEGP 69.00
Nescafé Clásico Instant CoffeeEGP 359.00
Each block reads through its own <Suspense> and <ErrorBoundary>, rendered in order. With one failing, the other still shows, and so does the rest of this page.
The moka read cache
Leptos on the serverCatalogue reads go through one moka cache keyed tenant:generation:kind:key. Entries expire after their TTL, and bumping a shop's generation drops all of its entries at once, without a scan.
Why it matters hereListings, rails and search results are computed once and served from memory to every visitor who asks the same thing. A catalogue edit is one counter bump, plus a CDN purge by Cache-Tag in production.
Live demo
- Entries
- 17
- Hits
- 9,965
- Misses
- 5,935
- Hit rate
- 63%
- This shop's generation
- 0
Bumping drops every cached read of this shop: the next page you open misses, then hits again.
Tenancy by Host
Leptos on the serverOne binary serves both shops. The shop is the first label of the Host header (nile.…, souq.…). It is resolved before rendering, so the logo, the name and the default language are in the first byte.
Why it matters hereAdding a shop is data, not a deployment. Hosts without a shop label, like a bare IP on the phone, fall back to a tenant cookie.
Live demo
- Host
- nile.leptos-preview.duckdns.org
- Picked by
- the Host's first label (the production path)
- Shop
- nile · Nile / نايل
- Default language
- English
- Catalogue
- 13 categories · 191 items
- Delivery
- EGP 45.00 · free over EGP 1,500.00
- Order codes
- NIL-48213
- Cache-Tag prefix
- tnile
This page at SouqAnother subdomain is another origin: a full page load, with its own cookies.
Head and meta
Leptos on the server<Title>, <Meta> and <Link> from leptos_meta are written synchronously from in-memory data. Title, description, canonical, OpenGraph and JSON-LD are all in the <head> of the first chunk.
Why it matters hereCrawlers and link previews read the first response and never run scripts. With in-order SSR and no head that waits for data, they see exactly what the shopper sees.
Live demo
- <title>
- How this storefront is built
- robots
- noindex
Read live from this document's <head>. After an in-app navigation it shows the new page's head, which the router brings along.
Simulated latency and faults
Leptos on the server/__sim?ms=350 sets a cookie for you alone. Every widget's read then waits a multiple of that base: deals 2.6×, related products 1.8×, search 0.25×. ?fail=<widget> breaks one read for one request, and /__sim?fail=deals breaks it for the whole session.
Why it matters hereOn localhost everything answers at once, which hides loading states, slow phones and partial failures. It's off by default, and it's how every skeleton, timeout and error state here was checked.
Live demo
Simulated latency is off: the blocks would all arrive at once. Pick a latency first.
Clear every faultA fault makes that widget's read fail on every page until you clear it. Try “deals”, then open the home page.
The TypeScript client
One small ES module, bundled by esbuild. It changes state on the spot and asks the server only for data it doesn't have.
The router and the page cache
The TypeScript clientcore/router.ts takes over same-origin links and forms. It fetches the next page's HTML, swaps the page's regions, and mounts the features in what came in. Pages are cached with a TTL and stale-while-revalidate, a hover (65 ms) or a finger-down prefetches, and refresh() skips the cache.
Why it matters hereNavigation feels instant without rendering pages in the browser. The server still renders every page, and the router only moves HTML. Without JavaScript the same links are ordinary page loads.
Live demo
- routing.pending
- false
- routing.to
- null
- Navigations in this tab
- 0
Prefetch, then open: the page comes out of the page cache. Hovering a link for 65 ms or putting a finger on it does the same.
One store per concern
The TypeScript clientcreateStore(initial) is the only state primitive: a value with subscribers. Each concern has exactly one store, created once in stores/*.ts: the cart, the toast queue, the router's pending state. Every feature that needs one imports that same store.
Why it matters hereThe header badge, the drawer and the cart page can't disagree when they read the same store. There's also one path per action: the cart changes only through stores/cart.ts.
Live demo
- cart
- —
- toasts
- —
- routing
- —
Each line is a live subscription. Fire a few toasts: the queue keeps three, newest in front, and the toaster draws them from the same store.
The cart lives in a cookie
The TypeScript clientThe browser owns the cart: lines like 2307:1:2 in the cart cookie, written only by stores/cart.ts. Adding, removing and changing a quantity happen on the spot, with no request. The server reads the same cookie only when it has to price it: on /cart, for the drawer's quote and at checkout.
Why it matters hereTaps never wait for the network, the cart survives reloads and stays in step across tabs, and no-JS forms change the very same cookie. Prices, stock and totals still come from the server, formatted.
Live demo
- cart cookie
- —
- Units in the cart
- —
- Requests for that change
- —
- Server's quote
- —
This is your real cart: the header badge and the drawer follow it. Adding and removing make no request; only “Ask the server” does, and it sends nothing but the cookie.
Templates filled by TypeScript
The TypeScript clientWhen the client has to show data, such as a toast, a cart line or a search suggestion, it clones a server-rendered <template id="tpl-…">, and fill() sets each [data-slot] as text. The markup and its translated labels are written once, in Rust.
Why it matters hereThere's no second copy of the UI in the client, no HTML built from strings, and nothing inserted as HTML. A change to a component's markup is made in one place, and the client picks it up.
The client data cache
The TypeScript clientcore/cache.ts keeps API reads and pages in memory: fresh for a TTL, then stale-while-revalidate (shown at once, refreshed in the background), then gone. Reads of one key at the same time share one request, and every key includes the shop, the language and the numerals.
Why it matters hereGoing back to a page or reopening the drawer shows data immediately, and a language switch can never show a cached page in the other language. A new catalogue generation from the server (x-catalog-gen) drops everything.
Live demo
- Key
- —
- Age
- —
- cache.ts stats()
- —
The value is the server's cache stats, cached here for 5 s fresh and 10 s more stale. Click a few times and watch the three cases.
Springs, from core/motion.ts
The TypeScript clientSpringValue animates a number with a damped spring, solved exactly on every frame. It can be interrupted: a new target starts from the live value and velocity. A flick hands over its release velocity, project() says where it would come to rest, and rubberband() resists past an edge.
Why it matters hereDrawers, sheets and the gallery follow the finger and leave the way they came, as on iOS. There's one implementation for every gesture, so nothing moves on a different curve.
Live demo
- Value
- 0
- Velocity
- 0
- Projected rest
- —
Drag the dot and let go. A slow release settles on the nearest stop; a flick is projected and lands where it would have come to rest. Past either end it rubber-bands.
The size of the client
The TypeScript clientAll the browser code is one ES module. It is type-checked by tsc, bundled by esbuild from crates/app/build.rs, embedded in the server binary and served at a content-hashed /js/app-….js that browsers keep for good.
Why it matters hereThe budget for the whole client is 30 KB gzip. The WASM islands it replaced cost 379–769 KB brotli on every page, before any page-specific chunk.
Live demo
- File
- /js/app-4EHXELV3.js
- Raw, as built
- 0.9 KB
- Code only (no source map), gzip
- —
- As your browser received it
- —
A release build: minified, no source map.
Decisions
What we built first, what it cost, and why we changed course.
Why we left WASM islands
DecisionsThe first version used Leptos islands: #[island] components compiled to WebAssembly and hydrated in the browser, with the islands router doing the navigation. It worked, and we measured it in Chromium against the release server.
Why it matters hereEvery page paid 379–769 KB of brotli WASM for a storefront that is mostly static, and hydration had race bugs that broke whole pages. Here are the measurements.
Live demo
- One bundle, brotli
- 769 KB
- Split build, main module
- 379 KB
- Rebuild after a one-line change
- 36–48 s (server alone: 15 s)
- An island still waiting for data when hydration ran panicked after an in-app navigation, and the panic stopped every island hydrated after it, the header's cart badge included.
- A lazy island built without --split made the JS glue import a placeholder module, so no island on any page hydrated.
- Values created for the browser inside a component body could be dropped on another server thread, which panicked and cut the HTML short, at random.
- The islands router needed about 400 lines of shim: head alignment, <noscript>, the browser cache, the pressed submit button, scroll and back.
What replaced it is measured live in “The size of the client”. See it