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
curlor 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
gizcommand, 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:
| Module | Use it for |
|---|---|
gizlang:http | The HTTP server (serve), Request, Response, headers, streamed replies; routes (router, Route, resolve, href), redirects, cookies, static files, URL encoding, gzip |
gizlang:html | The Html type that markup compiles to, each, when, Shadow (declarative shadow DOM) |
gizweb | GizWeb: 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:model | The record model: annotations, Shape, input checks, Rejection, DDL and JSON (Model your data) |
gizdb:store | The app's database: Engine, Database |
gizlang:sqlite | An embedded SQLite database |
gizlang:security | Signed session tokens |
gizlang:tls | HTTPS with your certificate |
gizlang:test | Assertions 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 andHtmlvalues 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.
gizmust be on yourPATH. - 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:
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;
};Noteis what the database stores: the tablenotes, withidas its key.AddNoteis 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:
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 aResult. Model.actionputs 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 ofNotefrom its declaration.Data.insertmakes a typed write from it.Data.writeruns it in one transaction.tryreturns 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:
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.findandData.oneread 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 aNote.Forms.compilemakes the form of an action once. The hidden field_go(Forms.GO) sends the browser to the new note after a valid post (:idis theidof the action's answer).Web.actionsadds the routes of the actions: a form posts to/_a/ID, an agent calls the name, and the tools are listed at/_tools.Web.pagewrites a page with the shell, the style and the ETag.openmakes 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:
// 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:
giz notes/main.gizlistening on http://127.0.0.1:61100In another terminal, post the form the way a browser does. The hidden field _go is in the form:
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/addNoteHTTP/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:
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{"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:
curl http://127.0.0.1:61100/_tools[{"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:
curl http://127.0.0.1:61100/
curl http://127.0.0.1:61100/notes/ccc1b3b1<!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 & 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:
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");
}giz test notes/main.test.giz0 cached, 2 run, 0 failedA test that needs the database runs the whole handler against a GizDB folder in a temporary
directory. Make notes/journey.giz:
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");
}giz notes/journey.gizjourney okStep 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 part | Meaning |
|---|---|
/users | A 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
%ZZgets400. found.params.get(name)gives the text.found.params.integer(name)gives anOption<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 toParams.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:
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;
}giz howto/routes.gizIt prints the URL that href made, and then serves requests:
/files/docs/a%20b.txtcurl 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/1the 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:
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:
pageis a number, and/t/abc/xanswers400with 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
pathvalue 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.
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)fromgizlang: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:
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:
// 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:
<!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:
clientcompiles 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:
- A native
<form method="post" action={…}>, with its URL from a route declaration. - A
POSTroute. Its handler reads the fields fromrequest.body. - Validation on the server. On failure,
422and the same form, with the typed values and an error message. On success,303to 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:httplimits a request body toHttp.MOST_BODYbytes (16 MiB).Forms.form(shape, fields)writes the form of a Shape, andWeb.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.
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 againstacceptandmaxBytes. A file in a field that takes none, or two files in a single-file field, is a422for 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 throughServing.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:
body { margin: 0; font-family: system-ui, sans-serif; }style.giz:
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
@importanother (inlined) orlayer()it;*.module.cssrenames 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'sFIELDin the element keyedKEY(as its text, or asATTRIBUTE) 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-cancelcontrol in a busy form stops its request. data-stateon the form isbusy,retrying,ok,errororcancelled, for CSS.- A rejected field gets
aria-invalid, its message is in anaria-liveslot that the control names witharia-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):
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().
curl -N --trace-time --trace-ascii - -o /dev/null http://127.0.0.1:61103/clock0000: 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. Afterrespond, the status is sent. - Call
exchange.finish()to end the response. - With GizWeb,
streamplaces 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.
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.- The element that shows the rows gets the attributes of
Web.live(out, topic, id). - The page kernel long-polls
/_s.Web.subscribedanswers 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; }.
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.gizHTTP/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 segmentHttp.filesets the media type,ETagandLast-Modified. It answers conditional requests,HEADand one byte range.- A path cannot leave the directory or follow a link.
Http.withHeaderadds a header, for examplecache-control.Http.assetgives 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:
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`);
}/aboutbecomesabout/index.htmland/becomesindex.html. A path with a file extension keeps its name.- A path that does not answer
200stops 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.staticPathsafter each deploy and answers those paths from the files withpublic, 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 /loginmakes a token forkimwithSecurity.token(secret, principal, issuedMs). It sets it withHttp.setCookie. The cookie hasPath=/,HttpOnlyandSameSite=Lax, andSecurewhen you passsecure;giz doc Http.setCookiegives the rules.GET /mereads the cookie withHttp.cookie, and checks it withSecurity.verified. A token is valid for 30 days (Security.SESSION_LIFE).
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/meHTTP/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.pbkdf2Sha256fromgizlang:cryptoif you store passwords, or deploy to GizStack and use its sign-in. - Add the
Secureattribute 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:
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:
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 ofX-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(seestack/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.gizhas functions that start withtest. It checks what needs noSystem: 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:
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:
3 cached, 0 run, 0 failedA 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:
giz build notes/main.giz -o notes-server
./notes-serverlistening 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 (
datain 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/:
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:
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;
}giz howto/https.giz "$PWD/tls/cert.pem" "$PWD/tls/key.pem"
curl -k -i 'https://127.0.0.1:61105/?q=two+words'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_DOMAINSset,Web.listenalso serves HTTP/3 (QUIC, UDP on the same port number) to a browser that has seen thealt-svcheader, when the server has OpenSSL 3.5 or later (giz opensslinstalls one). A handler does not change. Several requests of a browser share one connection and do not wait for each other.gizlang:acmegets 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:
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
holdMsmilliseconds. A request that is safe to repeat (GET,HEAD,OPTIONS,PUT,DELETE) goes to the next server. - A
POSTis never sent twice. The client gets502.502is also the answer when no server can be reached. - An answer of the server with a status such as
500is 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.
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:
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 (andcase="=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/pricingis Korean, and/pricingEnglish. 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 itshreflangalternates. - An application behind a sign-in keeps one address for every language with
byCookie(site)(gizweb:pages): the person's choice, then the browser'sAccept-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 thatmatch (found.target)handles each endpoint. WithWeb.route, the target is the handler, andWeb.servecalls 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, andHEADcome 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
| Problem | Effect | What 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,liveand 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/httpandexamples/web/notes.web/commentsis the complete reference app. - Deploy to GizStack: your app at its own address.