GizStack
View .mdOpen in ChatGPTOpen in Claude

Build web apps with GizWeb

This guide shows how to build a server-rendered web app with GizWeb. In about 20 minutes you model your data, add an action and two pages, keep the notes in a database, and test the app.

  • The guide is for developers who know Next.js, Remix or React.
  • The tutorial builds one app step by step. The how-to guides each do one task. The explanation compares the model with Next.js.
  • Each example ran on the current toolchain. Each response is the real response, from curl or from a browser. Long output is trimmed (…).
  • A section with a status badge describes GizWeb (gizweb). Every other section runs on the SDK modules of the released toolchain.
  • To install the toolchain, refer to the installation guide. For the giz command, refer to the command line guide.

What GizWeb is

GizWeb is a set of SDK modules. The compiler contains the SDK, so a project needs no other installation. You import the modules with a gizlang: name:

ModuleUse it for
gizlang:httpThe HTTP server (serve), Request, Response, headers, streamed replies; routes (router, Route, resolve, href), redirects, cookies, static files, URL encoding, gzip
gizlang:htmlThe Html type that markup compiles to, each, when, Shadow (declarative shadow DOM)
gizwebGizWeb: pages and the app shell, routes with a context (Web.route, Web.path, Web.typed, Web.before, Web.limited, Web.serve), static pages (Web.prerender), forms, actions, live views, content and data views
gizdb:modelThe record model: annotations, Shape, input checks, Rejection, DDL and JSON (Model your data)
gizdb:storeThe app's database: Engine, Database
gizlang:sqliteAn embedded SQLite database
gizlang:securitySigned session tokens
gizlang:tlsHTTPS with your certificate
gizlang:testAssertions for X.test.giz files

Markup in .giz files has the same syntax as TSX. Its descriptive name is GSX. There is no other file suffix and no other parser.

  • A lowercase tag writes HTML.
  • A capitalized tag calls a typed component function.
  • {value} writes escaped text. Strings, integers, floats and Html values can be children.
  • A markup expression has the type Html. Its bytes are in .markup.

Tutorial: build a small web app

Coming In development. This is the design it ships to. Roadmap

You build "Notes": a list of notes, one page for each note, and a form that adds a note. At the end, the notes are in a GizDB folder and stay after a restart. One record type gives the table, the form, the checks, the JSON API and the agent tool.

The finished app is examples/web/notes. The same app on gizlang:http alone, without a model, is examples/web/http.

Before you start

  • Install the toolchain. giz must be on your PATH.
  • Make an empty folder, and in it a folder notes. Run all commands from the empty folder.
  • The examples listen on port 61100 of 127.0.0.1. If that port is in use, change the number in notes/main.giz.

Step 1: write the model

A model is a record type with annotations. Make notes/model.giz:

gizlang
import {Table, Key, Label, Min, Max, Trim, Multiline, Submit} from "gizdb:model";

/** A saved note. */
@Table("notes")
export type Note = { @Key id: string; title: string; body: string; };

/** The form that adds a note. */
@Submit("Add note")
export type AddNote = {
    @Label("Title") @Min(1) @Max(80) @Trim title: string;
    @Label("Text") @Max(2000) @Multiline body: string;
};
  • Note is what the database stores: the table notes, with id as its key.
  • AddNote is what the form sends. Its annotations are the label, the limits and the trim of each field. They make the HTML form, the server checks and the JSON schema. The checks run on the server for every caller: a browser, an API client and an agent.

Step 2: write the action

An action is an ordinary function, listed once in a registry. Make notes/actions.giz:

gizlang
import * as Model from "gizdb:model";
import {Note, AddNote} from "./model.giz";
import {Data} from "gizweb";

/** What an action runs with: the database and a fresh id. */
export type Session = { db: Data.Db; fresh: string; };

function addNote(s: Session, input: AddNote): Result<Note, Model.Rejection> {
    const note: Note = { id: s.fresh, title: input.title, body: input.body };
    const shape = Model.shape<Note>();
    try Data.write(s.db, [try Data.insert(shape, note)], "");
    return Result.ok(note);
}
/** The actions: people (a form), pages and agents (a tool) call them by name. */
export function actions(): Vec<Model.Action<Session>> {
    return [Model.action<Session, AddNote, Note>("addNote", "Add a note.", addNote)];
}
  • The function gets what it runs with (Session) and the checked input. It gives a Result.
  • Model.action puts it in the registry with its name and its doc. The input type gives the form, the checks and the tool schema, read from the type at run time.
  • Model.shape<Note>() reads the Shape of Note from its declaration. Data.insert makes a typed write from it. Data.write runs it in one transaction.
  • try returns the error of a failed step at once.

Step 3: write the pages and serve

The app is in notes/app.giz, and notes/main.giz starts it. Make notes/app.giz:

gizlang
import * as Web from "gizweb";
import * as Model from "gizdb:model";
import * as Store from "gizdb:store";
import {System} from "gizlang:system";
import * as Http from "gizlang:http";
import * as Text from "gizlang:text";
import {Html, each} from "gizlang:html";
import {Data, Forms} from "gizweb";
import {Note} from "./model.giz";
import {Session, actions} from "./actions.giz";

/** What each serving thread shares. */
export type Site = { db: Data.Db; note: Model.Shape; shell: Web.Shell; add: Forms.Form; sys: System; };
type Handle = Web.Handle<Site>;

/** Each route once: its method, pattern and handler. Links come from the same declaration. */
type NotePath = { id: string; };
function notePath(): Web.Path<NotePath> { return Web.path<NotePath>("GET", "/notes/{id}"); }
function noteRoute(): Http.Route<Handle> {
    return Web.typed(notePath(), (site: Site, request: Http.Request, p: NotePath): Http.Response => {
        match (Data.one(site.db, site.note, "id = $1", [p.id], Model.rowReader<Note>(site.note), "No such note.")) {
            Result.ok(n) => { return Web.page(request, site.shell, n.title, <main><h1>{n.title}</h1><p>{n.body}</p></main>.markup, "no-cache"); }
            Result.err(r) => { return Web.respond(request, Result.err(r)); }
        }
    });
}
function homeRoute(): Http.Route<Handle> {
    return Web.route("GET", "/", (site: Site, request: Http.Request, p: Http.Params): Http.Response => {
        const notes = Data.find(site.db, site.note, "true", [], Model.rowReader<Note>(site.note))!;
        let form = b"";
        site.add.write(form, Model.noFields().with(Forms.GO, "/notes/:id"));
        const link = (n: Note): string => { return Web.link(notePath(), NotePath(n.id))!; };
        const body = <main><h1>Notes</h1><ul>{each(notes, (n: Note) => <li><a href={link(n)}>{n.title}</a></li>)}</ul>{Html(form)}</main>;
        return Web.page(request, site.shell, "Notes", body.markup, "no-cache");
    });
}

/** The engine of the GizDB folder `path`, with the tables of the models. */
export function open(sys: System, path: string): Store.Engine {
    const engine = Web.store(sys, path)!.engine()!;
    Data.install(Data.of (engine, "notes")!, [Model.shape<Note>()])!;
    return engine;
}
export function shell(): Web.Shell { return Web.shell("en", Model.handlers(actions()), Web.uiStyle()); }
/** The handler of one serving thread: its connection to the one engine of the process. */
export function app(own: System, engine: Store.Engine): (request: Http.Request) => Http.Response {
    const list = actions();
    const handlers = Model.handlers(list);
    const site: Site = {
        db: Data.of (engine, "notes")!,
        note: Model.shape<Note>(),
        shell: shell(),
        add: Forms.compile(handlers[0], ""),
        sys: own
    };
    const session = (s: Site, r: Http.Request): Session => {
        return { db: s.db, fresh: Text.hexEncode(s.sys.randomBytes(4)!) };
    };
    let routes = [homeRoute(), noteRoute()];
    for (const r of Web.actions(handlers, session, (
        s: Session, id: string, bare: bool, fields: Model.Fields
    ): Model.Outcome => {
        return Model.dispatch(list, s, id, bare, fields);
    })) {
        routes.push(r);
    }
    const router = Http.router(routes)!;
    return (request: Http.Request): Http.Response => { return Web.serve(router, site, request); };
}
  • A route is declared once, as a function: method, pattern and handler. noteRoute().href(params) makes the link from the same declaration, so a changed pattern changes each link.
  • Data.find and Data.one read records of a Shape with a filter. The values are bound ($1), never put into the SQL text. Model.rowReader<Note> reads a row into a Note.
  • Forms.compile makes the form of an action once. The hidden field _go (Forms.GO) sends the browser to the new note after a valid post (:id is the id of the action's answer).
  • Web.actions adds the routes of the actions: a form posts to /_a/ID, an agent calls the name, and the tools are listed at /_tools. Web.page writes a page with the shell, the style and the ETag.
  • open makes the tables at start. The process holds one engine of the GizDB folder. Each serving thread makes its own connection to it (Data.of).

Make notes/main.giz:

gizlang
// The notes app on GizWeb (app.giz): one model, one action, two pages. Run this file with gizstack.
import * as Web from "gizweb";
import {System} from "gizlang:system";
import * as Http from "gizlang:http";
import {open, shell, app} from "./app.giz";

function main(sys: System): i64 {
    const engine = open(sys, "data");
    return Web.listen(sys, 61100, shell(), Web.serving(), (own: System): (request: Http.Request) => Http.Response => {
        return app(own, engine);
    });
}

Run the app, and use it:

sh
giz notes/main.giz
text
listening on http://127.0.0.1:61100

In another terminal, post the form the way a browser does. The hidden field _go is in the form:

sh
curl -i -H 'Referer: http://127.0.0.1:61100/' --data-urlencode 'title=Plan trip' \
  --data-urlencode 'body=Book trains & hotel' --data-urlencode '_go=/notes/:id' http://127.0.0.1:61100/_a/addNote
text
HTTP/1.1 303 See Other
location: /notes/ccc1b3b1
…

The id is random, so yours is different. An empty title gets 303 back to the page, and the page shows the error next to the field. The same action answers a JSON client with the same check:

sh
curl -H 'content-type: application/json' -d '{"title":"","body":"x"}' http://127.0.0.1:61100/_a/addNote
curl -H 'content-type: application/json' -d '{"title":"From JSON","body":"b"}' http://127.0.0.1:61100/_a/addNote
text
{"ok":false,"status":422,"field":"title","message":"Title: Enter a value.","errors":[{"field":"title","message":"Title: Enter a value."}]}
{"ok":true,"value":{"id":"ea053105","title":"From JSON","body":"b"}}

An agent finds the action as a tool, with the schema that the annotations gave:

sh
curl http://127.0.0.1:61100/_tools
text
[{"name":"addNote","id":"addNote.6f1a…","description":"Add a note.","input_schema":{"type":"object","properties":{"title":{"type":"string","description":"Title","minLength":1,"maxLength":80},"body":{"type":"string","description":"Text","maxLength":2000}},"required":["title","body"]}}]

The list and a note are pages of server HTML. The text that the user typed comes back as escaped text:

sh
curl http://127.0.0.1:61100/
curl http://127.0.0.1:61100/notes/ccc1b3b1
text
<!doctype html>…<main><h1>Notes</h1><ul><li><a href="/notes/ccc1b3b1">Plan trip</a></li><li><a href="/notes/ea053105">From JSON</a></li></ul><form method="post" action="/_a/addNote" …>…</form></main></body></html>
<!doctype html>…<main><h1>Plan trip</h1><p>Book trains &amp; hotel</p></main></body></html>

Stop the server with Ctrl+C and start it again. The notes are still there, because they are in the folder data.

The same action answers the form, a JSON client and an agent. The checks are the same for all three.

Step 4: test the app

A pure test needs no server and no database. Make notes/main.test.giz:

gizlang
import * as Model from "gizdb:model";
import * as Test from "gizlang:test";
import {AddNote} from "./model.giz";

function testATitleIsRequired(): void {
    const fields = Model.formFields("title=+&body=x")!;
    match (Model.read(Model.shape<AddNote>(), fields)) {
        Result.ok(row) => { Test.check(false); }
        Result.err(r) => { Test.equal(r.status, 422); Test.includes(r.message, "Title"); }
    }
}
function testATitleIsTrimmed(): void {
    const row = Model.read(Model.shape<AddNote>(), Model.formFields("title=+Milk+&body=x")!)!;
    Test.equal(Model.valueOf<AddNote>(Model.shape<AddNote>(), row).title, "Milk");
}
sh
giz test notes/main.test.giz
text
0 cached, 2 run, 0 failed

A test that needs the database runs the whole handler against a GizDB folder in a temporary directory. Make notes/journey.giz:

gizlang
import {System} from "gizlang:system";
import * as Http from "gizlang:http";
import * as Test from "gizlang:test";
import {open, app} from "./app.giz";

function post(path: string, form: string): Http.Request {
    return Http.Request("POST", path, [Http.Header("content-type", "application/x-www-form-urlencoded")], utf8(form));
}
function main(sys: System): void {
    const dir = sys.temporaryDir()!;
    const handle = app(sys, open(sys, `${dir.temporaryPath()!}/data`));
    const added = handle(post("/_a/addNote", "title=Milk&body=2+liters&_go=%2Fnotes%2F%3Aid"));
    Test.equal(added.status, 303);
    const location = Http.header(added.headers, "location") ?? "";
    Test.check(location.startsWith("/notes/"));
    Test.includes(string(handle(Http.Request("GET", location, [], b"")).body), "<h1>Milk</h1>");
    Test.includes(string(handle(Http.Request("GET", "/", [], b"")).body), "Milk");
    Test.equal(handle(Http.Request("GET", "/notes/none", [], b"")).status, 404);
    const refused = handle(post("/_a/addNote", "title=+&body=x"));
    Test.equal(refused.status, 422);
    Test.includes(string(refused.body), "Title");
    Test.includes(string(handle(Http.Request("GET", "/", [], b"")).body), "Milk");
    dir.close();
    print("journey ok");
}
sh
giz notes/journey.giz
text
journey ok

Step 5: use the app in a browser

Open http://127.0.0.1:61100/. Type a title and a text, and click Add note. The browser goes to the new note. Go back to the list: it shows the note. The form works with no JavaScript.

You now have a server-rendered app with routes, a form, validation, a JSON API and a database. The next sections each do one task. Choose how to reach the data shows the other levels below the model.

How-to guides

Declare routes and read parameters

A route is Http.Route(METHOD, PATTERN, TARGET). TARGET is any value. Usually it is a union of endpoints.

Pattern partMeaning
/usersA literal segment
/users/{id}{id} captures one segment
/files/*path*path captures the rest of the path, with its slashes
  • A literal segment has priority over a capture. A capture has priority over a rest capture. The order of the declarations has no effect.
  • Two equal routes are refused when you call Http.router.
  • Each captured value is decoded one time. A bad escape such as %ZZ gets 400.
  • found.params.get(name) gives the text. found.params.integer(name) gives an Option<i64>.
  • route.href(Http.Params(names, values)) makes a URL from a declaration. It escapes each segment. A missing parameter is an error.
  • Http.query(request) decodes the query one time to Params. get(name) gives the first value, or "". A malformed query is an error with status 400 (Serve HTTPS has an example).

The how-to examples are in a folder howto, next to notes. Run them from the same folder as the tutorial.

howto/routes.giz:

gizlang
import {System} from "gizlang:system";
import {Result} from "gizlang:core";
import * as Http from "gizlang:http";

type Endpoint = | { kind: "readme"; } | { kind: "file"; } | { kind: "user"; };
function fileRoute(): Http.Route<Endpoint> { return Http.Route("GET", "/files/*path", Endpoint.file()); }
function main(sys: System): i64 {
    const routes = Http.router([
        Http.Route("GET", "/files/readme", Endpoint.readme()),   // a literal segment wins over a capture
        fileRoute(),                                             // *path captures the rest of the path
        Http.Route("GET", "/users/{id}", Endpoint.user()),       // {id} captures one segment
    ])!;
    print(fileRoute().href(Http.Params(["path"], ["docs/a b.txt"]))!);
    const listener = sys.listen("127.0.0.1", 61102)!;
    Http.serve(listener, (request: Http.Request): Http.Response => {
        match (routes.resolve(request)) {
            Result.err(response) => { return response; }   // 404, 405 with Allow, or 400 for a bad escape
            Result.ok(found) => {
                match (found.target) {
                    Endpoint.readme() => { return Http.text(200, "the readme"); }
                    Endpoint.file() => { return Http.text(200, "file " + found.params.get("path")); }
                    Endpoint.user() => { return Http.text(200, "user " + found.params.get("id")); }
                }
            }
        }
    })!;
    return 0;
}
sh
giz howto/routes.giz

It prints the URL that href made, and then serves requests:

text
/files/docs/a%20b.txt
sh
curl http://127.0.0.1:61102/files/readme
curl http://127.0.0.1:61102/files/docs/a%20b.txt
curl http://127.0.0.1:61102/users/kim%2Fx
curl -i http://127.0.0.1:61102/users/%ZZ
curl -i -X DELETE http://127.0.0.1:61102/users/1
text
the readme
file docs/a b.txt
user kim/x

HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
date: Wed, 14 Oct 2026 02:01:14 GMT
content-length: 28

Invalid path escape or UTF-8

HTTP/1.1 405 Method Not Allowed
content-type: text/plain; charset=utf-8
allow: GET, HEAD
date: Wed, 14 Oct 2026 02:01:14 GMT
content-length: 18

method not allowed

%2F in {id} stays in the one segment: the value is kim/x.

Parameters are text. Convert them with params.integer or your own check. With GizWeb, a route target is a typed record, so {id} is an i64 that the compiler checks.

Name the parameters of a route in a record

A parameter named in a pattern is text that a handler reads by name, and a misspelled name is found when the route runs. Web.path<P>(method, pattern) takes the parameters from a record P instead. The names in the pattern are exactly the field names of P. The program stops at start-up, not at the first request, when they differ:

gizlang
import * as Http from "gizlang:http";
import * as Web from "gizweb";

type Site = { name: string; };
type Thread = { target: string; page: i64; };

/** The route of a page of a thread. */
export function threadRoute(): Http.Route<Web.Handle<Site>> {
    const thread = Web.path<Thread>("GET", "/t/{target}/{page}");
    print(Web.link(thread, { target: "abc", page: 3 })!);   // /t/abc/3
    return Web.typed(thread, (site: Site, request: Http.Request, p: Thread): Http.Response => {
        return Http.text(Http.STATUS_OK, p.target + " " + string(p.page));
    });
}
  • A field type is checked and converted like a form field: page is a number, and /t/abc/x answers 400 with the field that is wrong.
  • Web.link(path, params) makes the URL and escapes each segment. A misspelled field is a compile error (E_FIELD), and a link cannot name a parameter the route does not have.
  • Use the same path value for the route and for its links.

Make layouts and components

A component is a function. It takes a props record, and, if it has children, an Html value. It returns Html.

gizlang
import {Html} from "gizlang:html";

type LayoutProps = { title: string; };
export function Layout(p: LayoutProps, children: Html): Html {
    return <html lang="en">
        <head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>{p.title}</title></head>
        <body><header><a href="/">Notes</a></header><main>{children}</main></body>
    </html>;
}
  • Call it as <Layout title="Notes">…</Layout>. A props value in braces is a typed expression: <Counter start={3}/>.
  • A nested layout is a component that calls another component.
  • Lists: {each(items, (n: Note) => <li>{n.title}</li>)}.
  • Conditions: {when(condition, () => <p>…</p>)}.
  • A component has no state and no lifecycle. It runs on the server for each request.
  • Text that you trust as HTML goes in with raw(text) from gizlang:html. Do not use it for user input.

Use Web Components

Use a Web Component when a part of the page needs browser behavior. The Shadow component of gizlang:html writes a declarative shadow root. The browser makes the shadow DOM from the HTML, before scripts run. Keep the meaningful content in light DOM, and show it through a <slot>.

howto/components.giz:

gizlang
import {System} from "gizlang:system";
import {Result} from "gizlang:core";
import * as Http from "gizlang:http";
import {Html, Shadow} from "gizlang:html";

type CounterProps = { start: i64; };
/** A Web Component rendered on the server: the shadow root is in the HTML. */
function Counter(p: CounterProps, children: Html): Html {
    return <note-counter>
        <Shadow delegatesFocus={false}><button type="button">+1</button> <output>{p.start}</output> <slot/></Shadow>
        {children}
    </note-counter>;
}
type Endpoint = | { kind: "home"; } | { kind: "asset"; };
function main(sys: System): i64 {
    const routes = Http.router([Http.Route("GET", "/", Endpoint.home()), Http.Route("GET", "/static/*path", Endpoint.asset())])!;
    const listener = sys.listen("127.0.0.1", 61104)!;
    Http.serve(listener, (request: Http.Request): Http.Response => {
        match (routes.resolve(request)) {
            Result.err(response) => { return response; }
            Result.ok(found) => {
                match (found.target) {
                    Endpoint.home() => {
                        const view = <html lang="en"><head><meta charset="utf-8"/><title>Counter</title>
                            <script type="module" src="/static/counter.js"></script></head>
                            <body><Counter start={3}><span>clicks</span></Counter></body></html>;
                        return Http.Response(200, [Http.Header("content-type", "text/html; charset=utf-8")], b"<!doctype html>" + view.markup);
                    }
                    Endpoint.asset() => {
                        const dir = sys.directory("howto/public")!;
                        const response = Http.file(dir, request, found.params.get("path"), sys.time());
                        dir.close();
                        return response;
                    }
                }
            }
        }
    })!;
    return 0;
}

howto/public/counter.js is a plain JavaScript module in a static folder:

js
// Upgrades the server-rendered <note-counter>. The shadow root already exists.
customElements.define("note-counter", class extends HTMLElement {
  connectedCallback() {
    const output = this.shadowRoot.querySelector("output");
    this.shadowRoot.querySelector("button").addEventListener("click", () => { output.value = String(Number(output.value) + 1); });
  }
});

Run giz howto/components.giz, and open http://127.0.0.1:61104/. The server sends:

text
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Counter</title><script type="module" src="/static/counter.js"></script></head><body><note-counter><template shadowrootmode="open" shadowrootclonable><button type="button">+1</button> <output>3</output> <slot></slot></template><span>clicks</span></note-counter></body></html>

In a Chromium browser, the element had its shadow root from the HTML. After two clicks, the output showed 5, and the light DOM <span> still said clicks.

  • The script does not make the DOM again. It only adds behavior to the DOM that the server sent.
  • With GizWeb, browser code can also be Gizlang: client compiles it to WebAssembly, and a live update replaces it in place.

Handle forms and validate input

GizWeb derives the whole pattern from one record type (Step 3): the form, its parser and its validation. Without a model, the pattern is:

  1. A native <form method="post" action={…}>, with its URL from a route declaration.
  2. A POST route. Its handler reads the fields from request.body.
  3. Validation on the server. On failure, 422 and the same form, with the typed values and an error message. On success, 303 to the result page.

examples/web/http is a complete app of this kind.

  • Http.decode(text, true) decodes one form value (+ is a space). It gives none for a bad escape.
  • gizlang:http limits a request body to Http.MOST_BODY bytes (16 MiB).
  • Forms.form(shape, fields) writes the form of a Shape, and Web.inputs(request, shape) reads and checks it. A file field (@File) makes the form multipart (Take files in a form).

For a form that changes data, also check that the request comes from your site. A browser sends Sec-Fetch-Site and Origin headers. Read them with Http.header(request.headers, name). Cookies from Http.setCookie are SameSite=Lax, so other sites do not send them with a cross-site POST.

Take files in a form

Mark a text field with @File(accept, maxBytes, multiple). Its form control is then a file input, and the form is multipart/form-data. The field holds the file name (a JSON list of names with multiple), so a Shape checks that a file was chosen like any other value.

gizlang
import {Option} from "gizlang:core";
import {Submit, File} from "gizdb:model";

@Submit("Send")
export type Attach = {
    note: string;
    @File("image/*,.pdf", 5000000, false)
    picture: string;
    @File("", 0, true)
    more: Option<string>;
};
  • Web.inputs(request, shape) reads the form and checks each file against accept and maxBytes. A file in a field that takes none, or two files in a single-file field, is a 422 for that field.
  • Web.uploads(request, shape) gives the files: Upload { field, name, mediaType, size, bytes }.
  • The kernel sends a form with a file input as FormData. A file dropped or pasted on the form fills its input. A form posted without script works the same.
  • The body is read whole (Http.MOST_BODY). A larger upload is streamed through Serving.uploads; store the bytes with Storage.

Keep styles and scripts in files

Write CSS and JavaScript as files and import them as text. Assets.minifyStyle and Assets.minifyScript make the served form.

site.css:

site.css
body { margin: 0; font-family: system-ui, sans-serif; }

style.giz:

gizlang
import {text as siteStyle} from "./site.css";
import {Assets} from "gizweb";

export function style(): string { return Assets.minifyStyle(siteStyle); }

For an app with many files, Assets.build(sources, roots, minify, maps) reads the whole graph:

  • a script imports another by its logical name (import {a} from "app/a.js"); import() makes a lazy module;
  • a style sheet may @import another (inlined) or layer() it; *.module.css renames its classes and :global(x) keeps one;
  • var(--x) with no fallback and no declaration, an import cycle, an import of a file that is no source, and a source that no root reaches are problems, not silent output;
  • graph.audit(forbidden) reads every served file for texts that must not leave the server (a secret, /home/).

Web.sources(shell, graph, entries) links the sheets, the import map, modulepreload and the entry scripts into every page.

See an edit at once

Under giz live on this machine, a page asks /_giz/dev which generation serves. A new generation updates the page in place, as a link does (the stylesheets sync, the form values stay); a changed kernel reloads it. If the new generation cannot start, or an edit does not compile, its reason or the compiler's error covers the page until an edit fixes it, while the generation before keeps serving. The terminal that runs live shows the same error.

Web.doctor(shell, graph, forbidden) lists what is wrong with the setup (an empty language or name, a graph problem, a served file over Web.HEAVY_BYTES), Web.doctorReport(found) prints it, and Web.weights(graph) sizes each file. Run them in a test so a regression fails the suite.

Answer slow or broken calls

A handler call that runs longer than Serving.limitMs is cancelled and answers 504; a fault answers 500. A browser gets a page and a program gets the JSON error that every refusal uses.

Update a page before the server answers

Put attributes on a form (data-a):

  • data-optimistic="KEY=FIELD KEY@ATTRIBUTE=FIELD" shows the form's FIELD in the element keyed KEY (as its text, or as ATTRIBUTE) at once. If the action fails, the old value comes back; the server's patches replace it when it succeeds.
  • data-retry="N" sends the action again after a network failure (not after a rejection), 300 ms, 600 ms, ... apart.
  • A data-cancel control in a busy form stops its request.
  • data-state on the form is busy, retrying, ok, error or cancelled, for CSS.
  • A rejected field gets aria-invalid, its message is in an aria-live slot that the control names with aria-describedby, and the first bad control takes the focus.

Use the form and interaction components

Web.Field, DateField, Combobox, Dialog, Menu and Chart use the native elements (input, datalist, dialog, popover, svg with a table for assistive technology), so they work without script, and the browser supplies the keyboard, the language and right-to-left layout. Combobox adds the giz-combobox element (Web.comboboxScript(), register it with Web.enhancement): the arrow keys move, Enter chooses, Escape closes, and an IME composition is left alone. Web.uiStyle() covers dark mode, prefers-contrast, forced-colors, prefers-reduced-motion, right-to-left and print.

Give an agent the contract

Web.contractRoute(shell, handlers, routes) serves /_contract: the version, the routes, the actions (the same list as the agent tools at /_tools), the subscription path and the served files. It is made from the declarations, so it cannot disagree with them.

Serve HTML without hydration

Each page in this guide is complete HTML from the server. The pages in the tutorial have no <script> element. There is no client component tree, no serialized props and no hydration step.

  • A link is a normal <a href>. A form is a normal <form>. The tutorial pages contain no script, thus they work the same with JavaScript disabled.
  • Add browser behavior only where it is necessary, as a Web Component (Use Web Components).
  • Static content is static bytes. A component does not go to the browser.

Stream a response

A handler can return ReplyPlan.progressive(...) in place of a Response. The server sends the head and each piece when you write it. Use it to send the start of a page before slow work ends.

howto/server.giz (this file is also used for static files and sessions below):

gizlang
import {System} from "gizlang:system";
import {Result, Option} from "gizlang:core";
import * as Http from "gizlang:http";
import * as Task from "gizlang:task";
import * as Security from "gizlang:security";

type Endpoint = | { kind: "asset"; } | { kind: "login"; } | { kind: "me"; } | { kind: "clock"; };
type Site = { sys: System; routes: Http.Router<Endpoint>; secret: bytes; };

function handle(site: Site, request: Http.Request): Http.ReplyPlan {
    match (site.routes.resolve(request)) {
        Result.err(response) => { return response.plan(); }
        Result.ok(found) => {
            match (found.target) {
                // Static files: ETag, Last-Modified, ranges and HEAD come from Http.file.
                Endpoint.asset() => {
                    const dir = site.sys.directory("howto/public")!;
                    const response = Http.file(dir, request, found.params.get("path"), site.sys.time());
                    dir.close();
                    return Http.withHeader(response, "cache-control", "public, max-age=3600").plan();
                }
                // Sessions: a signed token in an HttpOnly cookie.
                Endpoint.login() => {
                    const token = Security.token(site.secret, "kim", site.sys.time());
                    return Http.withHeader(Http.redirect(Http.STATUS_SEE_OTHER, "/me"), "set-cookie", Http.setCookie("session", token, Option.none<i64>(), false)!).plan();
                }
                Endpoint.me() => {
                    match (Security.verified(site.secret, Http.cookie(request, "session") ?? "", site.sys.time())) {
                        Option.some(signed) => { return Http.text(200, "Signed in as " + signed.who).plan(); }
                        Option.none() => { return Http.text(401, "Sign in first").plan(); }
                    }
                }
                // Streaming: the head and each piece go to the client when they are written.
                Endpoint.clock() => {
                    return Http.ReplyPlan.progressive((exchange: mut Http.Exchange): Result<bool, Http.HttpError> => {
                        try exchange.respond(200, [Http.Header("content-type", "text/html; charset=utf-8")], Http.UNKNOWN_LENGTH);
                        try exchange.write(<p>Loading…</p>.markup);
                        for (const n of range(1, 4)) {
                            Task.sleep(500);
                            try exchange.write(<p>Tick {n}</p>.markup);
                        }
                        return exchange.finish();
                    });
                }
            }
        }
    }
}
function main(sys: System): i64 {
    const routes = Http.router([Http.Route("GET", "/static/*path", Endpoint.asset()), Http.Route("POST", "/login", Endpoint.login()),
        Http.Route("GET", "/me", Endpoint.me()), Http.Route("GET", "/clock", Endpoint.clock())])!;
    const site: Site = { sys: sys, routes: routes, secret: sys.randomBytes(32)! };
    const listener = sys.listen("127.0.0.1", 61103)!;
    Http.serve(listener, (request: Http.Request): Http.ReplyPlan => { return handle(site, request); })!;
    return 0;
}

Run giz howto/server.giz. Http.serve accepts a handler that returns Response or ReplyPlan. A buffered response becomes a plan with .plan().

sh
curl -N --trace-time --trace-ascii - -o /dev/null http://127.0.0.1:61103/clock
text
0000: HTTP/1.1 200 OK
0000: content-type: text/html; charset=utf-8
0000: date: Wed, 14 Oct 2026 02:01:14 GMT
0000: transfer-encoding: chunked
20:43:38.793436 <= Recv data, 17 bytes (0x11)
0000: <p>Loading...</p>
20:43:39.294065 <= Recv data, 13 bytes (0xd)
0000: <p>Tick 1</p>
20:43:39.794659 <= Recv data, 13 bytes (0xd)
0000: <p>Tick 2</p>
20:43:40.295156 <= Recv data, 13 bytes (0xd)
0000: <p>Tick 3</p>

The pieces arrive 500 ms apart. (curl --trace-ascii shows the non-ASCII … as ....)

  • Make all decisions that change the status or the headers before respond. After respond, the status is sent.
  • Call exchange.finish() to end the response.
  • With GizWeb, stream places pieces into the page out of order, by key, through the 1.2 KB page kernel.

Live updates

Coming In development. This is the design it ships to. Roadmap

A GizWeb page follows the rows that it shows. When a row commits, the server sends keyed DOM patches for the changed elements only, and the page keeps typed text and focus.

  1. Data.follow(sys, db, shape, filter, keys) opens a watch on the rows, before the page reads them. It gives the id of the watch.
  2. The element that shows the rows gets the attributes of Web.live(out, topic, id).
  3. The page kernel long-polls /_s. Web.subscribed answers with the patches of the changed rows.

With gizlang:http alone, use a streamed response (Stream a response) for a page that changes while it is open.

Serve static files

Http.file(dir, request, path, nowMs) serves a file below a directory, as nginx serves root. In howto/server.giz above, the route GET /static/*path serves the folder howto/public. For this example, howto/public/site.css contains body { font-family: system-ui; }.

sh
curl -i http://127.0.0.1:61103/static/site.css
curl -i -H 'If-None-Match: "6accc7c7-21"' http://127.0.0.1:61103/static/site.css
curl -i http://127.0.0.1:61103/static/%2e%2e/routes.giz
text
HTTP/1.1 200 OK
content-type: text/css; charset=utf-8
last-modified: Mon, 12 Oct 2026 11:43:03 GMT
etag: "6accc7c7-21"
accept-ranges: bytes
cache-control: public, max-age=3600
date: Wed, 14 Oct 2026 02:01:14 GMT
content-length: 33

body { font-family: system-ui; }

HTTP/1.1 304 Not Modified
content-type: text/css; charset=utf-8
last-modified: Mon, 12 Oct 2026 11:43:03 GMT
etag: "6accc7c7-21"
accept-ranges: bytes
cache-control: public, max-age=3600
date: Wed, 14 Oct 2026 02:01:14 GMT

HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
date: Wed, 14 Oct 2026 02:01:14 GMT
content-length: 22

Forbidden path segment
  • Http.file sets the media type, ETag and Last-Modified. It answers conditional requests, HEAD and one byte range.
  • A path cannot leave the directory or follow a link.
  • Http.withHeader adds a header, for example cache-control.
  • Http.asset gives a file a content-hashed name, so it can be cached for a year.

Build static pages

Web.prerender(answer, paths, dir) asks answer for each path with a GET, as a browser does, and writes the answers to dir. The same routes and the same HTML writer make the pages. Run it when you deploy:

gizlang
import {System} from "gizlang:system";
import * as Http from "gizlang:http";
import * as Web from "gizweb";

/** The function that your server passes to `Http.serve`. */
function handle(request: Http.Request): Http.Response { return Http.text(Http.STATUS_OK, "a page"); }

function main(sys: System): void {
    sys.files()!.makeDirectories("public")!;
    const written = Web.prerender(handle, ["/", "/about", "/blog/first", "/feed.xml"], sys.directory("public")!)!;
    print(`${written.length} files`);
}
  • /about becomes about/index.html and / becomes index.html. A path with a file extension keeps its name.
  • A path that does not answer 200 stops the build with the path and the status in the error. A page that is missing never goes to production by chance.
  • The result is the list of the names written.
  • Serve the folder with Http.fileReply (Serve static files). No route handler runs and no context is needed. The pages have the bytes of the live pages.
  • Only static pages belong here. A page that depends on the person who asks (the session, the query) is served by its route.
  • GizStack does this for the content pages of its own site: it writes Content.staticPaths after each deploy and answers those paths from the files with public, no-cache.

Sessions and sign-in

gizlang:http reads and writes cookies. gizlang:security signs a session token with HMAC-SHA256. In howto/server.giz above:

  • POST /login makes a token for kim with Security.token(secret, principal, issuedMs). It sets it with Http.setCookie. The cookie has Path=/, HttpOnly and SameSite=Lax, and Secure when you pass secure; giz doc Http.setCookie gives the rules.
  • GET /me reads the cookie with Http.cookie, and checks it with Security.verified. A token is valid for 30 days (Security.SESSION_LIFE).
sh
curl -i http://127.0.0.1:61103/me
curl -i -c jar.txt -X POST http://127.0.0.1:61103/login
curl -b jar.txt http://127.0.0.1:61103/me
curl -b 'session=kim.1.00' http://127.0.0.1:61103/me
text
HTTP/1.1 401 Unauthorized
content-type: text/plain; charset=utf-8
date: Wed, 14 Oct 2026 02:01:14 GMT
content-length: 13

Sign in first

HTTP/1.1 303 See Other
location: /me
content-type: text/plain; charset=utf-8
set-cookie: session=kim.1791805408905.8eb182c76ef99f65fc43c57dc879fc23f6c509eca36cead8ed57ce9b2d33a1f1; Path=/; HttpOnly; SameSite=Lax
date: Wed, 14 Oct 2026 02:01:14 GMT
content-length: 3

/me

Signed in as kim

Sign in first
  • The example makes a new random secret at each start. Thus a restart signs out all users. In a real app, keep the secret in your database or in a protected file.
  • The example signs in without a password, to show the cookie flow only. Use Crypto.pbkdf2Sha256 from gizlang:crypto if you store passwords, or deploy to GizStack and use its sign-in.
  • Add the Secure attribute to the cookie when you serve HTTPS.

Protect routes with one policy

Coming In development. This is the design it ships to. Roadmap

A rule that holds for many routes is written once. Web.before(routes, check) runs check ahead of the handler of each route in the table. check gets the context, the request and the parameters. It answers in the handler's place, or gives Option.none() to let the route run:

gizlang
import {System} from "gizlang:system";
import {Option} from "gizlang:core";
import * as Http from "gizlang:http";
import * as Web from "gizweb";

type Site = { sys: System; secret: bytes; };

function page(path: string, text: string): Http.Route<Web.Handle<Site>> {
    return Http.Route("GET", path, Web.Handle((site: Site, request: Http.Request, p: Http.Params): Http.Response => {
        return Http.text(Http.STATUS_OK, text);
    }));
}
function signedIn(site: Site, request: Http.Request, p: Http.Params): Option<Http.Response> {
    match (Web.principal(site.secret, request, site.sys.time())) {
        Option.some(name) => { return Option.none(); }
        Option.none() => { return Option.some(Http.redirect(Http.STATUS_SEE_OTHER, "/login")); }
    }
}
export function routes(): Http.Router<Web.Handle<Site>> {
    let all = [page("/", "home"), page("/login", "sign in")];
    for (const r of Web.before([page("/account", "account"), page("/settings", "settings")], signedIn)) { all.push(r); }
    return Http.router(all)!;
}

A handler that returns a Result and meets a 401 sends a browser to /login through Web.respond. A feature flag and a maintenance page are written the same way. For a rate limit see Limit requests from one client.

Limit requests from one client

Web.limited(routes, limit, windowMs, proxies) answers 429 Too Many Requests with Retry-After to a client that makes more than limit requests within windowMs milliseconds. Other clients are not affected:

gizlang
import * as Http from "gizlang:http";
import * as Web from "gizweb";

type Site = { name: string; };

function page(path: string, text: string): Http.Route<Web.Handle<Site>> {
    return Http.Route("GET", path, Web.Handle((site: Site, request: Http.Request, p: Http.Params): Http.Response => {
        return Http.text(Http.STATUS_OK, text);
    }));
}
export function routes(): Vec<Http.Route<Web.Handle<Site>>> {
    return Web.limited([page("/", "home"), page("/search", "results")], 600, 60000, ["10.174.0.1"]);
}

To limit a whole server, make a limiter once with Web.limiter(limit, windowMs, proxies) and call limiter.admits(request) at the start of the handler. It gives Option.some(response) for a client over its limit. The counts live in memory. To keep them over a restart, save limiter.counted(wallMs, atLeast) (the clients with at least atLeast requests in the window) and give them back with limiter.restore(counts, wallMs) at start. giz saves them every 15 s for the clients with at least 60 requests, in the request_counts table.

  • The client is the address of the socket. The server puts it in the request header x-peer-address (Http.PEER_HEADER) and removes a header with that name that the client sent.
  • Behind a reverse proxy, every request comes from the proxy. List its address in proxies. For a connection from a listed proxy, the client is the last value of X-Forwarded-For: the proxy added it. The values before it are what the client wrote, and they can be forged. A client that is not a listed proxy cannot choose its address with this header.
  • A refused request is not counted. A client that is quiet for one window is forgotten.
  • The counts are in the memory of the process. A restart resets them.
  • GizStack reads the proxy addresses of its own site from STACK_PROXIES (see stack/README.md).

Test a web app

A handler is a function from Request to Response. Call it in a test. No server, port or browser is necessary. There are two kinds of test:

  • A test file X.test.giz has functions that start with test. It checks what needs no System: a Shape (the checks of a form), a route table, a component. The compiler certifies a pure test, and a test that passed is not run again.
  • A program with main(sys: System) checks what needs a database or a clock. It makes a GizDB folder in a temporary directory, builds the handler of one thread (app(sys, engine) in the tutorial) and calls it with requests. Step 4 of the tutorial shows both.

The router can be tested alone:

gizlang
import {Result} from "gizlang:core";
import * as Test from "gizlang:test";
import * as Http from "gizlang:http";

function testEqualRoutesAreRefused(): void {
    match (Http.router([Http.Route("GET", "/users/{id}", 1), Http.Route("GET", "/users/{name}", 2)])) {
        Result.ok(router) => { Test.check(false); }
        Result.err(message) => { Test.includes(message, "Ambiguous route"); }
    }
}
function testHrefEscapesEachSegment(): void {
    const route = Http.Route("GET", "/users/{id}", 1);
    Test.equal(route.href(Http.Params(["id"], ["a/b c"]))!, "/users/a%2Fb%20c");
    match (route.href(Http.Params([], []))) {
        Result.ok(url) => { Test.check(false); }
        Result.err(message) => { Test.includes(message, "id"); }
    }
}
function testAnInvalidPatternIsRefused(): void {
    match (Http.router([Http.Route("GET", "/users/{id", 1)])) {
        Result.ok(router) => { Test.check(false); }
        Result.err(message) => { Test.includes(message, "Captures must occupy a complete segment"); }
    }
}

The first run gives 0 cached, 3 run, 0 failed. A second run gives:

text
3 cached, 0 run, 0 failed

A test that passed before, and that the compiler certifies as pure, is not run again (cached). A failed assertion shows the test, the values and the line.

For browser journeys, use a browser automation tool against the running server.

Deploy a web app

Build a native executable, and run it on a server:

sh
giz build notes/main.giz -o notes-server
./notes-server
text
listening on http://127.0.0.1:61100
  • The executable runs on any x86-64 Linux server with glibc 2.35 or later: no build is for the CPU of the build computer.
  • The data folder (data in the tutorial) is relative to the working directory. Run the executable from the folder that holds the data, and back up that folder.
  • To deploy to GizStack instead, refer to Deploy to GizStack.

Serve HTTPS

gizlang:tls serves the same handler over TLS with your certificate. Tls.credentials reads the PEM chain and the PEM private key from their paths.

For a local test, make a self-signed certificate in tls/:

sh
mkdir -p tls
openssl req -x509 -newkey rsa:2048 -nodes -keyout tls/key.pem -out tls/cert.pem -days 1 -subj "/CN=localhost"

howto/https.giz:

gizlang
import {System} from "gizlang:system";
import {Result} from "gizlang:core";
import * as Http from "gizlang:http";
import * as Tls from "gizlang:tls";

// giz howto/https.giz /absolute/cert.pem /absolute/key.pem
function main(sys: System): i64 {
    const args = sys.arguments();
    const credentials = Tls.credentials(sys, args[0], args[1])!;   // PEM chain (own certificate first), PEM key
    const listener = sys.listen("127.0.0.1", 61105)!;
    Tls.serve(listener, credentials, (request: Http.Request): Http.Response => {
        match (Http.query(request)) {
            Result.ok(q) => { return Http.text(200, "Hello over TLS. q=" + q.get("q")); }
            Result.err(e) => { return Http.text(e.status, e.message); }   // 400: a malformed query
        }
    })!;
    return 0;
}
sh
giz howto/https.giz "$PWD/tls/cert.pem" "$PWD/tls/key.pem"
curl -k -i 'https://127.0.0.1:61105/?q=two+words'
text
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
date: Wed, 14 Oct 2026 02:01:14 GMT
content-length: 27

Hello over TLS. q=two words
  • The test used a self-signed certificate, thus curl -k. Use a certificate from a certificate authority on a public server.
  • The server speaks HTTP/1.1, and HTTP/2 when the browser asks for it in the TLS handshake (ALPN). With TLS_DOMAINS set, Web.listen also serves HTTP/3 (QUIC, UDP on the same port number) to a browser that has seen the alt-svc header, when the server has OpenSSL 3.5 or later (giz openssl installs one). A handler does not change. Several requests of a browser share one connection and do not wait for each other. gizlang:acme gets certificates from an ACME authority such as Let's Encrypt.

Share requests among servers

Http.proxy(pool, upstream, request) forwards a request to one server. Http.balancer(pool, upstreams, holdMs) shares requests among several. balancer.send(request) answers like Http.proxy:

gizlang
import {System} from "gizlang:system";
import * as Http from "gizlang:http";

function main(sys: System): void {
    const pool = Http.pool(sys, Http.poolDefaults());
    const servers = [Http.Origin(false, "10.0.0.2", 8080), Http.Origin(false, "10.0.0.3", 8080)];
    const balancer = Http.balancer(pool, servers, 5000);
    const listener = sys.listen("127.0.0.1", 61106)!;
    Http.serve(listener, (request: Http.Request): Http.Response => { return balancer.send(request); })!;
}
  • The servers get requests in turn.
  • A server that cannot be reached is not asked again for holdMs milliseconds. A request that is safe to repeat (GET, HEAD, OPTIONS, PUT, DELETE) goes to the next server.
  • A POST is never sent twice. The client gets 502. 502 is also the answer when no server can be reached.
  • An answer of the server with a status such as 500 is passed to the client as it is. It does not count as a server that cannot be reached.

Publish content

Coming In development. This is the design it ships to. Roadmap

GizWeb has a CMS: Markdown collections checked by a model, menus from one nav.md, blocks such as :::cards and :::steps, values bound from code, drafts and review, and /llms.txt, /llms-full.txt and sitemap.xml for free. This site runs on it.

gizlang
import {Dir} from "gizlang:system";
import {Html} from "gizlang:html";
import * as Model from "gizdb:model";
import * as Web from "gizweb";
import {Content} from "gizweb";

/** The pages under docs/ of ROOT, with the nav in nav.md: SHAPE types their frontmatter. */
export function docs(root: Dir, shape: Model.Shape, layout: (view: Content.View) => Html, labels: Web.Labels): Content.Loaded {
    const site = Content.Site("Docs", "https://example.com", "nav.md", "/play",
        [Content.Collection("docs", "/docs", shape, Content.Source.files("docs"))], [], [], layout, labels);
    return Content.load(root, site);
}

Speak more than one language

A site of .ghtml pages is written in English. Each run of text a person reads is one message, together with its holes and inline elements (Hello <b>{name}</b>!). A catalog locales/TAG.json beside the pages translates the messages:

sh
giz i18n . --add ko      # create locales/ko.json with every message, untranslated
giz i18n .               # add new messages, remove the ones no page shows any more
giz i18n . --check       # in CI: fail when a catalog is incomplete or a translation does not fit
  • A catalog maps each message, written in Unicode MessageFormat 2 (Hello {#b}{$name}{/b}!), to its translation. An untranslated message shows in English. The build fails on a translation whose holes or elements do not fit.
  • <template plural={count}> with a <template case="one"> for each case (and case="=0" for an exact count) is one message. Each translation uses its own language's plural cases: Korean has one, Arabic six.
  • Each language has its own address: /ko/pricing is Korean, and /pricing English. Links in a page keep the language. The language picker's links add ?hl=ko, and a cookie keeps the choice for a year. A browser that opens an English address while its languages say otherwise is sent to its own language's address (307); a crawler is not. Each page carries its hreflang alternates.
  • An application behind a sign-in keeps one address for every language with byCookie(site) (gizweb:pages): the person's choice, then the browser's Accept-Language, picks the language.

Explanation: how GizWeb differs from Next.js

Server HTML is the state

In Next.js, the server renders React components to HTML. Then the browser downloads the component code and the serialized props, and hydrates the page: React builds its tree again and attaches the event handlers.

In GizWeb, a component is a function that writes escaped HTML bytes on the server. Nothing goes to the browser except the HTML, and the scripts that you add yourself. There is no hydration step, because there is no client tree to build. A page works before any script loads, and it works with JavaScript disabled. Interaction is added at small boundaries, with native controls and Web Components that use the DOM that the server sent.

The cost is that a page does not change in the browser without a new request, a stream or your own script. GizWeb keeps the authoritative state on the server, and the browser keeps only local interaction state.

Routes are data, and their targets are typed

A Next.js route is a file path. A Remix route is a module in a route config. A GizWeb route is a value: method, pattern and a typed target. The router compiles all declarations one time, and refuses ambiguity. Thus:

  • With Http.Router<Endpoint>, the compiler checks that match (found.target) handles each endpoint. With Web.route, the target is the handler, and Web.serve calls it with the context of the app.
  • Links come from the same declaration (route.href(params)), so a changed pattern changes each link.
  • One policy runs ahead of many routes (Web.before).
  • 404, 405 with Allow, and HEAD come from the router, not from each handler.

Actions are ordinary typed functions

A Next.js Server Action is a function that the framework calls through a generated endpoint. In GizWeb, the form posts to a declared route, and the handler is an ordinary function with typed inputs and a typed Response. The form works without JavaScript. Validation stays on the server, and the answer follows Post/Redirect/Get: 303 on success, 422 with the form on failure.

GizWeb goes one step further: an Action is derived from one record type, with its validation, its form, its JSON answer and its agent tool description (GizWeb).

One process, native code

A GizWeb app compiles to one native executable. The HTTP server, the router, the HTML writer and the database driver are in the same program. There is no Node.js runtime and no separate bundler.

Known problems

ProblemEffectWhat to do
giz live publishes a new generation, but a running Http.serve server keeps the old answers.Code changes do not show until a restart.Restart the server, or serve with Web.listenOn and a Web.generation (See an edit at once).
When one test fails, the summary can count more failed tests than the tests that failed (3 run, 2 failed for one failure).The count is wrong. The fail lines are correct.Read the fail lines.

Where to go next

  • Command line guide: check, test, build, live and packages.
  • Start with Gizlang: the Playground and the markup example.
  • Packages: split an app into modules with a pinned manifest.
  • GizWeb: models, Actions, live views and the CMS.
  • Model your data: the DAL, typed queries, migrations and access.
  • The official examples (examples/): examples/language, examples/web/http and examples/web/notes. web/comments is the complete reference app.
  • Deploy to GizStack: your app at its own address.