Quick start: a guestbook#

Build and run a guestbook in about five minutes. Messages are stored in SQLite, rendered on the server, and returned as HTML — with no build step, no package manager, and no configuration file.

The app lives in docs/examples/guestbook if you would rather read it than type it.

Before you start#

You need bialet on your PATH. See Installation for other platforms.

curl -fsSL https://get.bialet.dev | sh
mkdir guestbook && cd guestbook

1. The table#

_migration.wren runs every time the app starts and applies only what it has not applied before. The name you give a migration is its key, so init runs once, on the first start.

Db.migrate("init", `
  CREATE TABLE messages (
    text TEXT,
    created_at DATETIME
  )
`)

The database is a file next to your app (_db.sqlite3), created on first run. There is no service to start and no connection string.

2. The page#

index.wren is the page at /. The file name is the route.

var messages = `
  SELECT * FROM messages ORDER BY created_at ASC
`.fetch

return <main>
  <h1>Bialet Guestbook</h1>
  <form method="post" action="/message">
    <input name="msg" placeholder="Write a message...">
    <button>Submit</button>
  </form>
  <hr>
  <ul>
    {{ messages.map {|m| <li>{{ m["text"] }}</li> } }}
  </ul>
</main>

Three things worth noticing:

  • Backticks build a prepared statement, not a string. .fetch runs it and returns the rows as a list of maps.

  • <main>…</main> is an inline HTML string. Its value is the response body.

  • {{ messages.map {|m| … } }} is a Wren expression. Plain values are escaped automatically, so text from the database cannot inject markup.

Pitfall: column values always come back as strings, including numbers. Convert before doing math — Num.fromString(row["count"]), or .toNum when a query returns a single number.

3. The form handler#

message.wren is the URL /message. It is a separate file because it does a different job, not because it needs wiring: no router, no table of routes.

if (Request.isPost) {
  var msg = Request.post("msg")
  if (msg) {
    `INSERT INTO messages VALUES (?, CURRENT_TIME)`.query(msg)
    return <main>
      <p>Messaged <b>{{ msg }}</b> saved!</p>
      <p><a href="/">Show messages</a></p>
    </main>
  }
}
return Response.redirect("/")
  • Request.post("msg") returns null when the field is missing. Check it before using it, or the next string operation crashes at runtime.

  • ? is a placeholder. Values are passed to .query(), never concatenated into the SQL, so injection is not a thing you can do by accident.

  • Response.redirect sends a 302. return stops the script so no body is attached to the redirect.

Run it#

bialet dev

bialet dev serves the directory on 127.0.0.1:7001, opens it in your browser, applies the migration, and reloads the page when a file changes. Errors show up in the browser instead of only in the terminal. Drop dev for the same server without live reload, error display, or auto-open.

Post a message and watch it appear. The message is in SQLite: delete _db.sqlite3 to start over, and the next start recreates it and reruns the migrations.

That is a working app. It also looks like 1995, which is the next problem.

4. A stylesheet#

Add a plain stylesheet. Bialet does not care where static files live, as long as the path does not start with _ or .; this app uses css/style.css. The file is ordinary CSS — custom properties, a gradient, one card:

:root {
  color-scheme: dark;
  --bg: #0b0f14;
  --line: #26313d;
  --text: #f2f6fa;
  --muted: color-mix(in srgb, #f2f6fa 62%, #0b0f14);
  --accent: #ffb224;
}

body {
  margin: 0;
  min-height: 100vh;
  padding: 56px 20px;
  display: grid;
  place-items: center;
  color: var(--text);
  font: 16px/1.55 system-ui, sans-serif;
  background:
    radial-gradient(110% 70% at 50% -10%, #1e1507, var(--bg));
}

.card {
  width: min(600px, 100%);
  padding: 34px;
  border: 1px solid var(--line);
  border-radius: 28px;
  background: linear-gradient(180deg, #161d25, #10161d);
  box-shadow: 0 40px 80px -40px #000;
}

The rest of the file styles the composer, the button, and the message list. The whole thing is 112 lines.

5. One layout class#

Two pages now share a <head> and a card wrapper. That belongs in one place. Protected files start with _, so _app.wren is importable from any page and never served as a URL:

class Template {
  static layout(content) { <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        <title>Bialet guestbook</title>
        <link rel="stylesheet" href="/css/style.css">
      </head>
      <body>
        <main class="card">
          <h1>Say something nice</h1>
          <p class="sub">Server-rendered, stored in SQLite.</p>
          {{ content }}
        </main>
      </body>
    </html> }
}

{{ content }} is where each page’s markup lands. Naming the file _app.wren is a convention, not a requirement — import "_app" for Template is the whole connection.

6. Use it#

Add the import and wrap the page in Template.layout(...):

import "_app" for Template

var messages = `
  SELECT * FROM messages ORDER BY created_at DESC
`.fetch

return Template.layout(
  <section>
    <form method="post" action="/message" class="composer">
      <input name="msg" placeholder="Write a message..." required>
      <button class="btn">Post</button>
    </form>
    <ul class="messages {{ Request.get("new") && "is-new" }}">
      {{ messages.map {|m| <li class="message">
        <p>{{ m["text"] }}</p>
        <time>{{ m["created_at"] }}</time>
      </li> } }}
    </ul>
  </section>
)
import "_app" for Template

if (Request.isPost) {
  var msg = Request.post("msg")
  if (msg) {
    `INSERT INTO messages VALUES (?, CURRENT_TIME)`.query(msg)
    return Template.layout(
      <section class="saved">
        <p>Saved <b>{{ msg }}</b></p>
        <a class="btn" href="/">Back to the guestbook</a>
      </section>
    )
  }
}
return Response.redirect("/?new=1")

Reload. Same routes, same database, same SQL — now inside a layout that loads the stylesheet. For the record, the URL bar showing ?new=1 is what makes the newest message keep its highlight for one render; it is a normal query string, read with Request.get("new").

What just happened#

  • The file is the route. index.wren → /, message.wren → /message. Nothing declares it.

  • SQL is SQL. Backticked statements are prepared; ? binds parameters.

  • HTML is a value. Pages return inline HTML strings; {{ }} inserts expressions and escapes them.

  • One binary. bialet served the files, opened the database, and applied the migration. Nothing was compiled, installed, or bundled.

The landing page video walks through the same two versions of this app.

Where to go next#