Wisp is in pre-release, self-hosted since GitHub suspended us. Install it, or see the changelog.

Wisp

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.

FileMeaning
+page.wispThe page
+page.mdA Markdown page; so is each x.md, at x
+page.rsThe block's Rust, as a file
+layout.wispWraps the page and all below; <slot /> or {@render children()}
+layout.rsload for the layout
+template.wispLike a layout, inside the layout of its folder, but fresh on each navigation to another path (Templates)
+error.wispErrors below; gets status, message
+loading.wispShown while a page below loads
@name/A slot (Slots)
+default.wispIn a @slot: no page fits
+page.jsBrowser load({ data, url, params, route, fetch, parent })
+layout.jsBrowser load of the layout. await parent() gives the data of the +layout.js loads above
+server.rsEndpoints: 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).

RuleForm
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.

ConditionHolds when
has:cookie:namethe request has the cookie (name=value: with that value)
has:header:namethe request has the header (name=value: with that value)
has:query:namethe query has the parameter (name=value: with that decoded value)
has:host:example.comthe 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.

  1. Fast: templates compile to push_str calls, routes to one match, no hot-path allocation.
  2. Cheap: few tokens in app code (Tokens, AGENTS.md).
  3. Durable: every fast path is proven at startup and falls back.
  4. Minimal dependencies: each needs a written reason.
  5. Safe: no unsafe code (workspace lint forbid) except the Linux I/O drivers, the edge exports and the Windows Job Object for wisp dev.
  6. Errors in the user's file: the build says where.
  7. Fast dev loop: markup edits never wait for cargo (design).
  8. Works without JavaScript: wisp.js enhances 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?

View This Page's Source

Type to search every docs page and heading.