Design and Files
Wisp is server-rendered HTML, file routes, .wisp templates, form actions and one binary. This page is the contract for v0.
An App
my-app/
Cargo.toml deps: wisp; build-deps: wisp-build
build.rs fn main() { wisp_build::run() }
src/main.rs wisp::main!();
src/app.html %wisp.head% and %wisp.body%
src/app.css Tailwind if it has @import "tailwindcss"
src/app.scss Sass, instead of app.css
postcss.config.* PostCSS (node)
package.json npm packages (`wisp npm add`)
src/hooks.rs `init` at start, `before` every request
src/components/ Card.wisp is <Card>
src/routes/ pages
src/db.rs any src/NAME.rs is the module `NAME`
static/ served as-is at /
Each src/NAME.rs (not main.rs, lib.rs, hooks.rs) is the module crate::NAME with no mod line. Templates reach it as NAME (db::find(id)), and its pub items by name alone (Pages).
Routes
Example: Layouts and Routes shows its code and downloads as a project.
Directory names are URL segments. Files starting with + are route files.
| File | Meaning |
|---|---|
+page.wisp | The page |
+page.md | A Markdown page; so is each x.md, at x |
+page.rs | The block's Rust, as a file |
+layout.wisp | Wraps the page and all below; <slot /> or {@render children()} |
+layout.rs | load for the layout |
+template.wisp | Like a layout, inside the layout of its folder, but fresh on each navigation to another path (Templates) |
+error.wisp | Errors below; gets status, message |
+loading.wisp | Shown while a page below loads |
@name/ | A slot (Slots) |
+default.wisp | In a @slot: no page fits |
+page.js | Browser load({ data, url, params, route, fetch, parent }) |
+layout.js | Browser load of the layout. await parent() gives the data of the +layout.js loads above |
+server.rs | Endpoints: get/post/put/patch/delete, #[derive(Rest)] (APIs) |
Segments: blog (static), [slug] (param), [[lang]] (optional), [...rest] (rest), (group) (not in the URL). A URL goes to the most specific route, left to right: static, then a param with a matcher, then a param, then an optional param, then a rest param. An optional route also matches without its segment, so /[[lang]]/hello takes /hello before /[slug]. A param may name a matcher, [id=int]: src/params/<name>.rs with fn matches(s: &str) -> bool. int is built in.
// src/params/locale.rs
fn matches(s: &str) -> bool {
matches!(s, "en" | "fr" | "de")
}
Priority: static, param with matcher, param, optional with matcher, optional, rest. Two routes with one pattern, an unknown matcher, a cx.param("name") the route lacks, a +-less page.wisp, a route deeper than 32 segments and a parameter named cx or starting __ are build errors. /about/ redirects (308) to /about.
Templates
A +template.wisp is a layout that the browser makes fresh. It sits inside the +layout.wisp of its folder. It takes the same block and must contain <slot /> or {@render children()}.
A client navigation keeps a layout: its DOM and the state of its browser code stay. A template starts again when the path changes. The browser replaces its element, so its islands, scripts and enter animations run again. A form answer on the same path keeps it. Use it for a state that each page needs new, or an animation on each page.
The server writes the template inside <wisp-template style="display:contents" id="wt…:/path">. The id holds the path. Do not put {@render children()} of a layout in an element that the HTML parser moves it out of (a <table>, say). A folder cannot hold both a +template.wisp and an @slot. An app with no template has no server cost.
Under <wisp-template>, a CSS child selector such as main > p does not reach through the wrapper. Write main p.
Slots (Parallel Routes)
A @name folder beside a +layout.wisp is a slot, drawn with {@render name()}.
src/routes/dash/
+layout.wisp <main>{@render children()}</main><aside>{@render feed()}</aside>
+page.wisp
@feed/+page.wisp drawn at /dash
@feed/[id]/+page.wisp drawn beside [id]/+page.wisp
@feed/+default.wisp drawn where none fits
A slot page needs a page at the same URL pattern. Each has its own struct Data, fn load, {#await}, +loading.wisp and +error.wisp, and no <title>, actions, endpoints, MIDDLEWARE, RATE_LIMIT, CORS, TIMEOUT, COMPRESS or CACHE. A failed slot load shows in the slot and the page stays 200. The slot is <wisp-slot id="slot-feed">, so keep {@render feed()} out of a <p>, table part or <select>. A soft navigation keeps a slot showing its default. wisp route list shows slot pages as @feed rows. Intercepting routes go in a slot: @modal/(.)photo/[id]/+page@.wisp (Parity features).
Holes (Partial Prerendering)
{#await} on a page with CACHE or PRERENDER stays in the kept page, unless its future reads cx. Then it is a hole. The shell is rendered once and each request's answers stream into it as private, no-store. Details.
Trailing Slash
/about/ gets a 308 to /about. wisp::trailing_slash(Always) in init flips it, Ignore serves both. A page or layout can set const TRAILING_SLASH: TrailingSlash = TrailingSlash::Always;.
Sitemap, Robots, Feed and Open Graph
/sitemap.xml, /robots.txt and /feed.xml come from the route tree, and a file in static/ wins. Addresses start with SITE_URL, else the request's host (x-forwarded-proto when WISP_CLIENT_IP_HEADER names a proxy). The sitemap skips (private) pages and noindex ones. A sitemap file holds at most 50 000 addresses and 50 MiB. Over that, /sitemap.xml is a sitemap index (sitemaps.org) that lists /sitemap-1.xml, /sitemap-2.xml and so on, and the files hold the addresses in order. /robots.txt still names /sitemap.xml. There are 10 files at most (500 000 addresses). The sitemap skips the addresses over the limit of 10 files (500 000 addresses), and the server logs once the count it skips. The log names the route that went over first when the count of addresses is the cause. The server builds the whole sitemap once a minute for all files. An edge build makes one file of the first 50 000 addresses, with no index, and skips the others with no log. On an edge build the sitemap holds every address of entries() in memory. The feed lists Markdown pages with a date, titled SITE_TITLE. wisp build --static writes all three (the sitemap and robots when SITE_URL is set), and the child sitemap files if the sitemap has any.
wisp::og(title, description, image) gives Open Graph tags: {@html wisp::og("Hello", "A first post", "/cover.png")}. Image "auto" writes an SVG to static/og/. For PNG, enable the og-png feature on wisp and wisp-cli.
Markdown Pages
Example: A Blog in Markdown shows its code and downloads as a project.
pulldown-cmark turns +page.md and each x.md into markup at build time. A README.md is no page. Headings get GitHub-style ids.
---
title: Hello
layout: Post
date: 2026-10-01
---
Text, and a component:
<Card title="x">
**Markdown** inside, between blank lines.
</Card>
Front matter is name: value lines, - in a name is _. Each value is on the line of its name. Wisp does not read a list or a | block on the next lines. title is the <title>. layout names a component in src/components that gets each field its {@props} declare. noindex: true adds a robots meta. Fenced code is highlighted at build time as <span class="hl-k|s|c|n|t|a|f|v|p">. wisp::pages("blog") gives a folder's MdPages (path, title, get("date")), newest first: {#each wisp::pages("blog") as p}<a href=${p.path}>${p.title}</a>{/each}.
Config Rules
redirects, rewrites and headers in [package.metadata.wisp] are lists of strings, checked at build (App::REDIRECTS, REWRITES, HEADERS).
| Rule | Form |
|---|---|
redirects = ["/old/[id] /new/[id] 301"] | from to [status] |
rewrites = ["/g/[...p] /docs/[...p]"] | from to |
headers = ["/api/[...p] x-a: b"] | pattern name: value |
cors = ["/api/[...p] https://app.example.com"] | pattern origins (* or sites), preflight included |
A redirect, rewrite or header rule can end with conditions, as has and missing of Next.js. A header rule puts them before its name: value. The rule applies only when all of its conditions hold.
| Condition | Holds when |
|---|---|
has:cookie:name | the request has the cookie (name=value: with that value) |
has:header:name | the request has the header (name=value: with that value) |
has:query:name | the query has the parameter (name=value: with that decoded value) |
has:host:example.com | the Host header as the client sent it, without its port, is that host (example.com. is not example.com) |
missing:... | the same item is not there, or has another value |
redirects = ["/beta /new 302 has:cookie:beta"]
rewrites = ["/g/[...p] /docs/[...p] has:header:x-docs=1"]
headers = ["/[...p] missing:cookie:id x-guest: 1"]
A condition reads what the client sends. It is not an access check.
A redirect with a cookie, header or host condition must use 302 or 307, because browsers and caches keep 301 and 308. A query condition is part of the URL, so any status is correct. A cookie, header or host condition on a redirect or rewrite makes the page private: no shared cache or kept page keeps it. On a header rule it adds the cookie, header or host to Vary. An endpoint behind a conditional rewrite must send Vary itself.
The HTTP/2 server, the edge, tower and WebSocket paths read the body limit before routing, and skip each rewrite with a condition there. A route that only a conditional rewrite reaches gets WISP_BODY_LIMIT and no upload spool on those paths.
A static export writes only the header rules with no condition into _headers, because a host cannot check a condition there.
Principles
In order: fast, cheap in tokens, durable, flexible.
- Fast: templates compile to
push_strcalls, routes to onematch, no hot-path allocation. - Cheap: few tokens in app code (Tokens, AGENTS.md).
- Durable: every fast path is proven at startup and falls back.
- Minimal dependencies: each needs a written reason.
- Safe: no unsafe code (workspace lint
forbid) except the Linux I/O drivers, the edge exports and the Windows Job Object forwisp dev. - Errors in the user's file: the build says where.
- Fast dev loop: markup edits never wait for
cargo(design). - Works without JavaScript:
wisp.jsenhances forms and links.
Release JavaScript
Release builds minify the browser JavaScript the app owns: component modules, src/lib modules and +page.js load modules (comments and whitespace go; names stay). The build checks the result: if its tokens differ from the source's, or a line break that ends a statement is lost, the module is served as written. wisp build --sourcemap skips minification, so the maps match the files, and a last //# sourceURL= line is kept so traces name the .wisp file. This is build time only; the request path is unchanged.
Dependency Budget
Runtime: tokio, libc (Linux), httparse. Build: pulldown-cmark, brotli (build time only: embedded text and prerendered pages as .br; it never links into an app). Off by default: bytes, http, http-body, tower-service (feature tower), tokio-rustls, webpki-roots (feature tls), image (feature img), ravif (feature avif), resvg (feature og-png). Not used: hyper, axum, tower, serde, notify, syn/quote. Written ourselves: the HTTP loop, multipart, the template compiler, SHA-256 and HMAC, a strict JSON parser. No ciphers.
Workspace
crates/wisp runtime
crates/wisp-build compiler
crates/wisp-shared shared with the browser
crates/wisp-macros #[action], #[derive(Cookie)], #[derive(Json)], #[derive(FromJson)]
crates/wisp-cli `wisp new | dev | build | check | lsp | mcp | update-docs`
editors/ examples/ tests/ bench/
Is This Page Useful?