Routing#

Bialet routing is exactly like serving static HTML files. A file named about.wren is the route /about, just as about.html would be. The .wren extension is optional in the URL.

The primary way to pass dynamic data is via query strings, not path segments. A file named article.wren at /article can read ?id=42 with Request.get("id") — no _route.wren needed. This keeps your project purely file-based, just like static HTML: one file, one URL, any number of query-string variations.

If you’ve ever put an HTML file in a folder and served it with a static file server, you already understand Bialet routing. There is no router to configure, no app.get(...) calls, no urlpatterns list. You put a file where you want a URL, and the URL exists.

Quick Start#

Here’s the entire mental model in one directory tree:

my-site/
├── index.wren          # /
├── about.wren          # /about
└── article.wren        # /article  (dynamic via ?id=...)

The rule: a file’s path relative to the project root, minus the .wren extension, is its URL.

  • index.wren at the root of a folder serves that folder’s URL (/, /blog).

  • about.wren serves /about or /about.wren — both work.

  • article.wren serves /article — and reads whatever comes after the ? to decide what to render.

// File: article.wren
// Handles: /article?id=42

var id = Request.get("id")
var article = `SELECT title, body_html FROM articles WHERE id = ?`.first([id])

if (!article) return Response.notFound()

return <main>
    <h1>{{ article["title"]}}
    {{ article["body_html"] }}
</main>

No new file, no special name, no _route.wren — just a normal .wren file reading Request.get("id"), the same way a static-page backend would read $_GET['id'].

Mental model: Bialet resolves URLs by walking the file system. You never declare routes explicitly — the layout of your project is the routing table. Dynamic data comes from the query string; dynamic routes are a rare, separate case covered later in Path-Based Dynamic Routes (Advanced).

⚠️ Pitfall: /about.wren and /about both serve about.wren, but the .wren extension is never required in links you write. Always link to the extension-less form.

Note: if you truly need the dynamic value in the path/article/42 instead of /article?id=42 — Bialet supports that too, via a special _route.wren file. Treat it as an advanced escape hatch, not your starting point; see Path-Based Dynamic Routes (Advanced).

The rest of this page fills in the details: direct mapping, protected files, dynamic content via query parameters, path-based routes (advanced), a full example project, and external imports.

Direct File Mapping#

Every .wren file maps to a URL path the same way a static HTML file would.

contact-us.wren  →  /contact-us
                 →  /contact-us.wren   (extension optional)
landing/newsletter/cool-campaign.wren  →  /landing/newsletter/cool-campaign

File

URL

index.wren

/

contact-us.wren

/contact-us and /contact-us.wren

landing/newsletter/cool-campaign.wren

/landing/newsletter/cool-campaign

blog/index.wren

/blog

This is not a regex router. There’s no pattern matching, no wildcards, no route priority to reason about. A file either exists at that path or it doesn’t. For dynamic data, start with Dynamic Content with Query Parameters below — reach for Path-Based Dynamic Routes only if the value truly needs to live in the URL path.

Protected Files#

Files and folders starting with _ or . are protected. They cannot be accessed directly via URL, no matter what extension they have.

⚠️ Pitfall: protected files return 403 Forbidden, not 404. If you get a 403 on a path you expected to be a 404, check whether a parent folder starts with _ or ..

Preferred: the _app/ folder#

Keep application-wide files inside a single protected folder:

_app/template.wren    # Application-wide template (header, footer, nav)
_app/migration.wren   # Database migrations
_app/cron.wren        # Scheduled tasks
_app/domain.wren      # Domain-specific configuration (optional)
_db.sqlite3           # SQLite database file

Configuration note: Bialet doesn’t use .env files. Configuration lives in the BIALET_CONFIG table inside your SQLite database (_db.sqlite3). Each environment has its own database file, so configuration is environment-specific by construction. See the Config class reference for managing configuration values.

Alternative: root-level special files#

You can place special files directly at the root instead, each prefixed with _:

_app.wren        # Application-wide template
_migration.wren  # Database migrations
_cron.wren       # Scheduled tasks
_route.wren      # Dynamic route handler (advanced, see below)

Both approaches work identically. The _app/ folder keeps your root directory cleaner; use root-level files if you prefer fewer nested folders.

Ignored files#

Some files are ignored entirely and are never served, protected or not: README*, AGENTS*, LICENSE*, *.json, *.yml, *.yaml. Keep documentation, AI agent instructions, and config files in your project without worrying about them leaking through routing.

Dynamic Content with Query Parameters#

This is the default way to make a page dynamic in Bialet — reach for it first, every time. Any .wren file, with no special name and no extra file, can read query parameters with Request.get(name). It’s the same model a static HTML page would use if it handed off to a server-side script reading $_GET in PHP, or req.query in Express — except here, every .wren file already has that ability, by default.

// File: blog.wren
// Handles: /blog?id=42

import "_app/template" for App

var id = Request.get("id")
var post = `
  SELECT title, content, createdAt, author
  FROM posts
  WHERE id = ? AND published = 1
`.query(id).fetch()

if (!post) {
  Response.status(404)
  return "<h1>Post not found</h1>"
}

return App.render({
  <article>
    <h1>{{post["title"]}}</h1>
    <p class="meta">By {{post["author"]}} on {{post["createdAt"]}}</p>
    <div class="content">{{post["content"]}}</div>
  </article>
})

One file (blog.wren) now serves every post on the site — /blog?id=1, /blog?id=2, /blog?id=9001 — with no additional files and no routing configuration to maintain.

The same pattern composes for anything you’d otherwise be tempted to put in the path, including simple API-style endpoints:

// File: api.wren
// Handles: /api?action=users&id=1  or  /api?action=posts&id=42

var action = Request.get("action")
var id = Request.get("id")

if (action == "users") {
  var userId = Num.fromString(id)
  var user = `SELECT * FROM users WHERE id = ?`.first([userId])
  return user

} else if (action == "posts") {
  var post = `SELECT * FROM posts WHERE id = ?`.first([Num.fromString(id)])
  return post
}

URL

Request.get("action")

Request.get("id")

Request.get("fields")

/api?action=users&id=1

"users"

"1"

null

/api?action=posts&id=42

"posts"

"42"

null

/api?action=users&id=1&fields=name,email

"users"

"1"

"name,email"

⚠️ Pitfall: don’t reach for _route.wren just because you have a single dynamic page. Query parameters are simpler, more cache-friendly, and keep your project structure flat. Use path segments only when the URL truly represents a resource hierarchy (like a REST API) or you need the SEO benefit of human-readable slugs.

Path-Based Dynamic Routes (Advanced)#

When to use this: only when the dynamic value must live in the URL path itself — a human-readable slug for SEO (/blog/how-to-cook-rice), a REST-style resource path (/api/users/123), or because you’re preserving a URL structure you inherited and can’t change. If none of those apply, use query parameters instead — see the pitfall above. This is an escape hatch, not a starting point, and most Bialet projects never need it.

Fixed files can’t cover URLs with variable path segments. For those rare cases, create a _route.wren file in the relevant directory. Bialet falls back to it whenever no static file matches the requested path.

// File: api/_route.wren
// Handles URLs like: /api/users/123 or /api/posts/my-slug

var segment = Request.route(0)  // First dynamic segment
var id = Request.route(1)       // Second dynamic segment

if (segment == "users") {
  var userId = Num.fromString(id)
  var user = `SELECT * FROM users WHERE id = ?`.first([userId])
  return user

} else if (segment == "posts") {
  var slug = id
  var post = `SELECT * FROM posts WHERE slug = ?`.first([slug])
  return post
}

⚠️ Pitfall: dynamic segments start at index 0, not 1. Request.route(0) is the first segment after the _route.wren file’s own directory.

URL

Request.route(0)

Request.route(1)

Request.get("fields")

/api/users/1

"users"

"1"

null

/api/posts/hello-world

"posts"

"hello-world"

null

/api/users/1?fields=name,email

"users"

"1"

"name,email"

Each segment is captured as a single path component — this is not a regex router. Request.route(n) never matches across a /. Query parameters still work alongside path segments, as the fields column above shows — the two aren’t mutually exclusive, you’re just choosing where the primary identifier lives.

You don’t need one global _route.wren for the whole site. Create a separate _route.wren in each directory that needs path-based handling — blog/_route.wren, admin/posts/_route.wren, api/_route.wren can all coexist and handle their own subtree independently.

How a Request Is Resolved#

                 ┌────────────────────┐
                 │  Incoming request  │
                 └──────────┬─────────┘
                            │
                            v
              ┌─────────────────────────────┐
              │ Matching .wren file exists?  │
              └───────┬─────────────┬───────┘
                   yes │             │ no
                       v             v
              ┌────────────────┐   ┌───────────────────────────────┐
              │ Execute that   │   │ _route.wren exists in this    │
              │ file           │   │ directory?                    │
              └────────────────┘   └───────┬───────────────┬───────┘
                                        yes │               │ no
                                            v               v
                              ┌─────────────────────────┐ ┌──────────────┐
                              │ Execute _route.wren with │ │ 404 Not Found│
                              │ Request.route(n) segments│ └──────────────┘
                              └─────────────────────────┘

Protected paths (_/. prefixed) short-circuit this diagram entirely — they return 403 before file resolution is attempted.

Complete Example Project Structure#

A blog application using the _app/ folder approach, with query parameters as the default dynamic-content strategy:

my-blog/
├── _app/                  # Protected folder for app configuration
│   ├── template.wren      # Site-wide template (header, footer, nav)
│   ├── migration.wren     # Database schema setup
│   ├── cron.wren          # Scheduled tasks (optional)
│   └── domain.wren        # Domain config (optional)
│
├── _db.sqlite3            # SQLite database
│
├── index.wren             # Homepage (/)
├── about.wren             # About page (/about)
├── contact.wren           # Contact page (/contact)
├── article.wren           # Single post, by ?id= (/article?id=42)
│
├── blog/
│   └── index.wren         # Blog list (/blog)
│
├── admin/
│   ├── index.wren         # Admin dashboard (/admin)
│   ├── login.wren         # Admin login (/admin/login)
│   └── posts/
│       ├── index.wren     # Post list (/admin/posts)
│       ├── new.wren       # Create post (/admin/posts/new)
│       └── _route.wren    # Edit post (/admin/posts/:id)
│
├── api.wren                # API endpoint, by ?action=&id= (/api?action=posts&id=42)
│
├── css/
│   └── style.css          # Static CSS
│
└── js/
    └── main.js            # Static JavaScript

Optional alternative: both blog and api could instead be built with _route.wren for path-based URLs — blog/_route.wren for /blog/my-first-post, api/_route.wren for /api/posts/456. Only do that if you need the slug or a REST-style path; see Path-Based Dynamic Routes (Advanced). The structure above uses query parameters instead, which is simpler for most projects.

URL

File Executed

Purpose

/

index.wren

Homepage

/about

about.wren

About page

/blog

blog/index.wren

Blog post list

/article?id=42

article.wren

Single post, looked up by ?id=

/admin

admin/index.wren

Admin dashboard

/admin/posts/new

admin/posts/new.wren

Create new post form

/admin/posts/123

admin/posts/_route.wren

Edit post with ID 123

/api?action=posts&id=456

api.wren

API endpoint for post 456, via query params

/_app

403 Forbidden

Protected file

Example: Article Page (Query Parameters)#

File: article.wren

// Import shared layout from _app folder
import "_app/template" for App

var id = Request.get("id")

if (!id) {
  Response.redirect("/blog")
  return
}

// Fetch post from database
var post = `
  SELECT title, content, createdAt, author
  FROM posts
  WHERE id = ? AND published = 1
`.query(id).fetch()

if (!post) {
  Response.status(404)
  return "<h1>Post not found</h1>"
}

// Render using App template
return App.render({
  <article>
    <h1>{{post["title"]}}</h1>
    <p class="meta">By {{post["author"]}} on {{post["createdAt"]}}</p>
    <div class="content">
      {{post["content"]}}
    </div>
  </article>
})

URL: /article?id=42

Note: if you’re using the root-level structure instead of _app/, import from _app directly: import "_app" for App

⚠️ Pitfall: post["content"] is trusted content you control (it came from your own database), but if you ever interpolate user-submitted text into HTML this way, escape it first with .safe to avoid XSS. Never build SQL by string-concatenating request input — use parameterized queries (? placeholders) as shown above to avoid SQL injection.

Optional: The Same Page as a Path-Based Slug#

Only do this if you need /blog/my-first-post in the address bar instead of /article?id=42 — typically for SEO, or because you’re matching a URL structure you don’t control. It requires a slug column and an extra file; most projects don’t need it, and the query-parameter version above should be your default.

File: blog/_route.wren

import "_app/template" for App

var slug = Request.route(0)

if (!slug) {
  Response.redirect("/blog")
  return
}

var post = `
  SELECT title, content, createdAt, author
  FROM posts
  WHERE slug = ? AND published = 1
`.query(slug).fetch()

if (!post) {
  Response.status(404)
  return "<h1>Post not found</h1>"
}

return App.render({
  <article>
    <h1>{{post["title"]}}</h1>
    <p class="meta">By {{post["author"]}} on {{post["createdAt"]}}</p>
    <div class="content">
      {{post["content"]}}
    </div>
  </article>
})

⚠️ Pitfall: this version needs a dedicated slug column and gives up the simplicity of a single query-string-driven file. Only add it if the SEO or URL-structure requirement is real — see Path-Based Dynamic Routes (Advanced).

Routing Compared: Bialet vs Express vs Django#

The same feature — fetching one blog post dynamically — looks different depending on whether routing is file-based or code-based, and on whether the dynamic value lives in a query string or the path. Bialet’s default is the query string; Express and Django default to the path.

Bialet — idiomatic: query parameter, no route table:

// File: blog.wren
var id = Request.get("id")
var post = `SELECT * FROM posts WHERE id = ?`.first([id])
return post

URL: /blog?id=42. The route exists because the file exists — there’s no separate list of routes to keep in sync with your handlers, and no special file needed for this, the common case.

Express — code-based, explicit route table, path parameter:

app.get('/blog/:slug', (req, res) => {
  const post = db.get('SELECT * FROM posts WHERE slug = ?', req.params.slug)
  res.send(post)
})

You register the path pattern and handler together, usually in a routes file that grows as the app grows.

Django — code-based, urlpatterns + views, path parameter:

# urls.py
urlpatterns = [
    path('blog/<slug:slug>/', views.post_detail),
]

# views.py
def post_detail(request, slug):
    post = Post.objects.get(slug=slug)
    return render(request, 'post.html', {'post': post})

Django separates the URL pattern (urls.py) from the view function (views.py) — two files to keep aligned for every route.

Bialet (idiomatic)

Bialet (path-based, advanced)

Express

Django

Route declared by

file path

file path + _route.wren

app.get(...) call

urlpatterns entry

Dynamic value from

Request.get("id")

Request.route(n)

req.params.slug

<type:name> in path

Handler location

same file as the route

same file as the route

inline callback

separate views.py

Adding a route

add a file

add a _route.wren file

add a line to a routes file

add a path() and a view

Typical use

default — most pages

rare — SEO slugs, REST APIs

default

default

The path-based Bialet row exists for parity with how Express and Django work by default. In Bialet it’s the exception, not the rule — most projects never write a _route.wren file.

If you’re coming from Express or Django, the biggest mental shift isn’t file-based vs. code-based routing — it’s that the dynamic value usually comes from the query string, not the path. Reach for Request.get(...) by default; reach for Request.route(n) only when you have a concrete reason to.

External Imports#

Bialet supports importing external Wren modules from remote sources, so you can use community-created libraries without manually downloading and managing them.

Import Syntax#

2. Full URL#

import "https://example.com/path/to/module.wren" for ClassName
  • You must include the .wren extension in the URL.

  • The URL must return raw Wren source code, not HTML.

  • For GitHub, use raw.githubusercontent.com URLs.

import "https://raw.githubusercontent.com/4lb0/emoji/main/emoji.wren" for Emoji

How It Works#

  1. Cache check — Bialet checks whether the module is already cached in the BIALET_REMOTE_MODULES database table.

  2. Download — if not cached, it downloads the module via HTTP GET.

  3. Validation — checks for a 2xx status code.

  4. Store — the content is stored in the database, keyed by import path.

  5. Load — the module is loaded and made available to your code.

The first load needs an internet connection; every load after that reads from the local database — fast, and works offline. Cached modules persist until you explicitly clear them.

⚠️ Pitfall: cached modules don’t auto-update. If you push a fix to the main branch of an imported module, running instances keep using the old cached copy until you clear the cache. This is deliberate — treat it as a stability feature, not a bug.

Cache Management#

External modules are cached in _db.sqlite3, table BIALET_REMOTE_MODULES:

CREATE TABLE IF NOT EXISTS BIALET_REMOTE_MODULES (
  module TEXT PRIMARY KEY,      -- The import path (e.g., "gh:user/repo/path")
  content TEXT,                 -- The cached Wren source code
  createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
)
-- Clear all cached external modules (forces re-download on next import)
DELETE FROM BIALET_REMOTE_MODULES;

-- View all cached modules
SELECT module, createdAt FROM BIALET_REMOTE_MODULES;

-- Check cache size
SELECT COUNT(*), SUM(LENGTH(content)) as total_bytes
FROM BIALET_REMOTE_MODULES;

After clearing the cache, restart your Bialet application (or trigger a reload) to re-download the modules.

GitHub URL Format#

gh:owner/repo/path@branch is internally converted to:

https://raw.githubusercontent.com/owner/repo/refs/heads/branch/path.wren

Import Statement

Generated URL

gh:user/lib/utils@dev

https://raw.githubusercontent.com/user/lib/refs/heads/dev/utils.wren

gh:org/pkg/sub/module@v1.0

https://raw.githubusercontent.com/org/pkg/refs/heads/v1.0/sub/module.wren

  • The .wren extension is added automatically.

  • Default branch is main if unspecified.

  • The path must resolve to a valid Wren file in the repository.

  • Invalid paths or missing files trigger error messages in the logs.

Error Handling#

“Invalid GitHub URL” — the import path doesn’t follow gh:owner/repo/path; owner, repo, or file path is missing.

“Module not found in GitHub” — file doesn’t exist at that path, the branch/tag doesn’t exist, the HTTP request returned a non-2xx status, or there’s a network problem.

“Import type not supported” — the import uses a protocol other than gh:, http://, or https://.

Check the Bialet logs for details:

System.print("Debug: attempting import...")

Security Considerations#

⚠️ Pitfall: external imports download and execute code from remote sources, with the same privileges as your application. Treat an import statement like adding a dependency, not like a link.

  • Verify the source before importing.

  • Review the code on GitHub when possible.

  • Use version tags, not main, for stability and predictability.

  • Cache behavior is a security feature — once downloaded, a module won’t silently change under you.

// ✅ Good: well-known, maintained library, pinned version
import "gh:4lb0/emoji/emoji@1.0" for Emoji

// ❌ Avoid: unknown source, unpinned, unverified
import "gh:random-user/suspicious-lib/module" for SomeClass

Multiple Versions#

import "gh:user/lib/module" for Module as ModuleLatest
import "gh:user/lib/module@v1.0" for Module as ModuleV1

// Use specific version based on your needs
var result = ModuleV1.someFunction()

Troubleshooting#

Module not found:

  • Visit the GitHub URL in a browser to confirm the path exists.

  • Check your internet connection (required for first-time downloads).

  • Confirm the branch name — defaults to main, not master.

Import fails silently:

  • Check the Bialet server logs.

  • Confirm the module returns raw Wren code, not HTML or an error page.

  • Try the full URL format to isolate GitHub-shorthand issues.

Cached version is outdated:

DELETE FROM BIALET_REMOTE_MODULES WHERE module LIKE 'gh:user/repo%';

Then restart your Bialet application. Prefer version tags over branches going forward.

“Import type not supported”:

  • Use gh:, http://, or https:// only — no ftp://, no file://.

  • Check for typos in the import statement.

Performance Tips#

  1. Use version tags to avoid unnecessary cache invalidation.

  2. Import only what you need — every import adds to first-load time.

  3. Pre-cache modules in development before deploying.

  4. Monitor cache size if you import many large modules.

Key Takeaways#

  • Query parameters are the default — any .wren file reads dynamic data with Request.get(name); no special file or extra route needed.

  • File-based routing — every .wren file is a route, exactly like a static HTML file.

  • Protected files — anything starting with _ or . is inaccessible directly and returns 403.

  • Path-based routes are the exception_route.wren plus Request.route(n) exists for SEO slugs and REST-style APIs; reach for it rarely, and only with a concrete reason.

  • No route table — the file system is the routing table. Nothing to register, nothing to keep in sync.

  • External importsgh: shorthand or full URLs, cached in BIALET_REMOTE_MODULES, never auto-updating once cached.