Skip to content

Quill docs

Common patterns & examples

This page is a practical cookbook for building a real Quill app. Each pattern is a small, complete recipe drawn from the actual starter the quill new command generates and the real Quill crate APIs — adding a page, wiring a reactive signal into an island, showing conditional content, composing several islands on one page, customizing the theme, and deploying. Every recipe is something you can paste into a fresh app and run. The throughline is the framework's core promise: server-rendered HTML with zero JavaScript by default, and interactive islands that hydrate only the subtrees that need them, shipping only the behaviors each page actually uses.

Pattern 1: Add a page#

A page is a Rust module under src/app/routes/ that exports pub fn respond(input: &[u8]) -> FunctionResponse. Adding one is three edits and a restart. The single source of truth for routes is the PAGES array in src/main.rs, which both serve and build walk.

rust
// a. Write the route — src/app/routes/about.rs
//! GET /about — a static page.

use qexec::FunctionResponse;
use qquill_view::{view, Node};

use crate::app::{document, respond_html};

fn page() -> Node {
    view! {
        main {
            h1 { "About" }
            p .lead { "This page was added by hand in three steps." }
        }
    }
}

pub fn respond(_input: &[u8]) -> FunctionResponse {
    let tree = document("About", Node::Fragment(vec![]), page());
    respond_html(&tree)
}
rust
// b. Register the module — add to src/app/routes/mod.rs
pub mod about;
rust
// c. List the page — add an entry to PAGES in src/main.rs
Page { id: "about", path: "/about", handler: app::routes::about::respond },

That is the whole contract. cargo run and /about is live and exportable. The document(title, extra_head, content) helper wraps your body in <html><head>…<body> with the theme CSS inlined; pass Node::Fragment(vec![]) for extra_head when the page needs no extra <head> children. respond_html(tree) renders to bytes, prefixes <!doctype html>, and frames the response with a Cache-Control header.

Pattern 2: A signal + island#

An *island* is a self-contained interactive component. The server renders a correct static fallback (so it works with JavaScript disabled), and the client runtime hydrates just that subtree on its declared trigger. The island(...) constructor takes the instance id, the behavior kind, the hydration trigger, a props JSON string, and the static fallback node.

rust
use qquill_view::{el, island, text, Node, Trigger};

fn counter(instance_id: &'static str, start: i64) -> Node {
    // The static fallback the server renders (works with JS disabled).
    let fallback = el("div")
        .class("counter")
        .child(
            el("output")
                .attr("data-q-bind", "count")
                .child(text(start.to_string())),
        )
        .child(
            el("button")
                .attr("type", "button")
                .attr("data-q-action", "inc")
                .child(text("+1")),
        );

    // Props the client behavior re-instantiates from on hydration.
    let props = format!("{{\"start\":{start}}}");

    // (instance id, behavior kind, hydration trigger, props JSON, fallback).
    // Trigger::Load hydrates as soon as the runtime boots.
    island(instance_id, "counter", Trigger::Load, props, fallback)
}

The data-q-bind (a display/value target the behavior reads and writes) and data-q-action (an action hook on a control) attributes are the contract the SSR markup and the client counter behavior share. The props string is escaped for the <script> sidecar at render time.

Rather than hand-writing the props JSON and the bind attribute, you can use the qquill-signal crate to keep an id, an initial value, an inert bound node, and the props entry in sync. A Signal is server-inert and client-reactive: on the server it is a plain holder (initial value + stable id) that renders the initial value into static HTML; the reactive behavior lives on the client, which re-creates the signal from props and the data-q-bind markers.

rust
use qquill_signal::{signal_id, Signal};

// An explicit id so a hand-written client behavior can reference it by name.
let count: Signal<i64> = signal_id("count", 0);

count.id();          // "count"            — the data-q-bind value + props key
count.get();         // &0                 — the server-authoritative initial value
count.text_value();  // "0"                — inert text of the current value
count.text_node();   // <span data-q-bind="count">0</span>  — inert bound node
count.props_entry(); // ("count", JsonValue) — fold into the island's props JSON

Request

use qquill_view::{island, el, text, render, node::Trigger};
let n = island(
    "counter-1",
    "counter",
    Trigger::Visible,
    "{\"count\":0}",
    el("button").child(text("0")),
);
let html = render(&n);

Response

html.contains("data-q-island=\"counter-1\"")  // == true

Output

The island renders its fallback markup tagged with the instance id (data-q-island), the kind, the trigger, and a props sidecar; on the client, the matching behavior hydrates that subtree when its trigger fires. Trigger::Visible here defers hydration until the island scrolls into view.
Trigger variantdata-q-trigger valueWhen it hydrates
Trigger::LoadloadAs soon as the runtime boots
Trigger::VisiblevisibleWhen the island scrolls into view
Trigger::InteractioninteractionOn first user interaction with the island
Trigger::IdleidleWhen the main thread is idle

Drop the island into a page body and return it through respond_html as usual. Because the tree contains an island, respond_html automatically appends the props sidecar plus the single runtime <script>, which carries only the behaviors this page uses.

Pattern 3: Conditional content#

The view layer is a builder over a Node tree, so conditional content is plain Rust — there is no special template syntax. Build the node you want with normal if/match, and use Node::Fragment(vec![]) (or fragment([])) as the empty/no-op node, exactly as document uses it for an absent extra_head.

rust
use qquill_view::{el, fragment, text, Node};

fn banner(logged_in: bool, name: &str) -> Node {
    if logged_in {
        el("p").class("lead").child(text(format!("Welcome back, {name}.")))
    } else {
        // Render nothing: an empty fragment emits no markup.
        Node::Fragment(vec![])
    }
}

fn status(items: usize) -> Node {
    match items {
        0 => el("p").child(text("Your cart is empty.")),
        1 => el("p").child(text("1 item in your cart.")),
        n => el("p").child(text(format!("{n} items in your cart."))),
    }
}

Build a list of children with an iterator and feed them in with .children(...). Use text(...) for escaped content and raw(...) only for trusted, pre-built markup (the theme CSS is injected as Node::Raw for exactly this reason).

rust
use qquill_view::{el, text, Node};

fn todo_list(items: &[String]) -> Node {
    el("ul").children(
        items.iter().map(|it| el("li").child(text(it.clone()))),
    )
}

// Empty state vs. populated, chosen at render time:
fn todos(items: &[String]) -> Node {
    if items.is_empty() {
        el("p").class("lead").child(text("Nothing to do — add a task."))
    } else {
        todo_list(items)
    }
}

Pattern 4: Multiple islands on one page#

A page can host several islands. Each island(...) needs an instance id that is unique within the page; multiple instances of the same kind are fine (give them distinct ids). The runtime bundle respond_html injects carries only the behavior kinds the page uses, deduplicated — a page with three distinct kinds ships CORE plus exactly those three behavior modules, not the full bundle.

rust
use qexec::FunctionResponse;
use qquill_view::{el, island, text, Node, Trigger};

use crate::app::{document, respond_html};

fn page() -> Node {
    el("main")
        .child(el("h1").child(text("Dashboard")))
        // Two counters of the SAME kind — distinct instance ids.
        .child(island("likes",  "counter", Trigger::Load,        "{\"start\":0}",
            el("output").attr("data-q-bind", "count").child(text("0"))))
        .child(island("shares", "counter", Trigger::Visible,     "{\"start\":0}",
            el("output").attr("data-q-bind", "count").child(text("0"))))
        // A different kind — hydrates only on interaction.
        .child(island("copy-1", "copy",    Trigger::Interaction,  "{}",
            el("button").attr("type", "button").child(text("Copy"))))
}

pub fn respond(_input: &[u8]) -> FunctionResponse {
    let tree = document("Dashboard", Node::Fragment(vec![]), page());
    respond_html(&tree) // injects CORE + counter + copy behaviors, deduped
}

How the bundle is assembled: respond_html calls page_has_islands(tree); if true, it calls collect_island_kinds(tree) to gather the kinds, writes the props sidecars with collect_island_sidecars_into, and appends <script> + runtime_bundle_for(&kinds) + </script> before </body>. runtime_bundle_for emits CORE, then each requested behavior module exactly once (in a fixed deterministic order, independent of how you ordered the islands), then the boot module last so every behavior is registered before hydration arms.

Behavior kindNotes
counterThe reference counter behavior (shares a module with `dialog`).
copyCopy-to-clipboard.
revealScroll/visibility reveal.
tabs / accordion / drawer / menu / popover / tooltipCommon interactive widgets, each its own module.
theme / theme-controlTheme switching behaviors.

Pattern 5: Theme customization#

The whole look-and-feel lives in src/app/theme.rs. Styles are authored with the style!{} macro from qquill-style, compiled to a compact CSS string at build time (no runtime cost), and inlined into every page's <head> by document. css() is called once per page render and returns the CSS string via block.to_css().

rust
// src/app/theme.rs — edit the tokens to re-skin the whole app.
pub fn css() -> String {
    let block = qquill_style::style! {
        ":root" {
            "--bg": "#0b0c10";
            "--surface": "#16181d";
            "--fg": "#e8e8ea";
            "--muted": "#9aa0aa";
            "--accent": "#7c9cff";
            "--radius": "10px";
        }
        "body" {
            "margin": "0";
            "background": "var(--bg)";
            "color": "var(--fg)";
            "font-family": "system-ui, -apple-system, Segoe UI, Roboto, sans-serif";
            "line-height": "1.6";
        }
        ".card" {
            "background": "var(--surface)";
            "border-radius": "var(--radius)";
            "padding": "1.25rem 1.5rem";
        }
        ".counter button" {
            "background": "var(--accent)";
            "color": "#0b0c10";
            "border": "0";
            "border-radius": "var(--radius)";
            "cursor": "pointer";
        }
    };
    block.to_css()
}

To re-skin the app, change the design tokens under :root (background, surface, foreground, muted, accent, radius) and the rules that reference them via var(...). Because the CSS is inlined into the document head by document, your theme changes apply to both the live server and the static export with no extra steps. A page can also add its own page-specific <style> by passing it as the extra_head argument to document.

Pattern 6: Deploy#

Once your pages render the way you want, export a static site and ship it. From inside the app directory, quill build renders every static route, copies public/, and writes the Cloudflare Pages control files into dist/.

Request

cd myapp
quill build
npx wrangler pages deploy dist

Response

Exported 2 page(s) to dist/
  dist/index.html
  dist/counter/index.html
Copied 1 asset(s) from public/
Deploy dist/ to Cloudflare Pages — it serves with no DMS running.

Output

dist/ is a self-contained static site: HTML per route (pretty URLs), copied public/ assets, and _headers / _redirects / 404.html. It serves with no DMS, no server process — just a CDN.

For a Git-connected Cloudflare Pages project, set the build command to cargo run --release -- build and the build output directory to dist. Quill writes _headers (per-page Cache-Control plus the immutable /assets/* rule), 404.html (served for unmatched paths), and a placeholder _redirects, so no extra Pages configuration is needed.

Dynamic routes (/users/:id, /files/*rest) are skipped during export and reported as skipped dynamic route ... — a static file tree has no params. The starter's .gitignore already ignores /dist, /target, and Cargo.lock.