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.wrenat the root of a folder serves that folder’s URL (/,/blog).about.wrenserves/aboutor/about.wren— both work.article.wrenserves/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.wrenand/aboutboth serveabout.wren, but the.wrenextension 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/42instead of/article?id=42— Bialet supports that too, via a special_route.wrenfile. 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 |
|---|---|
|
|
|
|
|
|
|
|
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
.envfiles. Configuration lives in theBIALET_CONFIGtable 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 |
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
⚠️ Pitfall: don’t reach for
_route.wrenjust 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, not1.Request.route(0)is the first segment after the_route.wrenfile’s own directory.
URL |
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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
blogandapicould instead be built with_route.wrenfor path-based URLs —blog/_route.wrenfor/blog/my-first-post,api/_route.wrenfor/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 |
|---|---|---|
|
|
Homepage |
|
|
About page |
|
|
Blog post list |
|
|
Single post, looked up by |
|
|
Admin dashboard |
|
|
Create new post form |
|
|
Edit post with ID 123 |
|
|
API endpoint for post 456, via query params |
|
❌ 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_appdirectly: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.safeto 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
slugcolumn 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 + |
|
|
Dynamic value from |
|
|
|
|
Handler location |
same file as the route |
same file as the route |
inline callback |
separate |
Adding a route |
add a file |
add a |
add a line to a routes file |
add a |
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#
1. GitHub Shorthand (Recommended)#
import "gh:owner/repo/path/to/file" for ClassName
Fetches from the main branch by default. Target a specific branch or tag
with @:
import "gh:owner/repo/path/to/file@branch-or-tag" for ClassName
// Import emoji utilities from main branch
import "gh:4lb0/emoji/emoji" for Emoji
// Import from specific version/branch (recommended for stability)
import "gh:4lb0/emoji/export@1.0" for Emoji as EmojiV1
// Import from a specific tag
import "gh:username/mylib/module@v2.1.0" for MyClass
The
.wrenextension is automatically appended — don’t include it in the import path.Default branch is
mainif you don’t specify one.Use
@branchor@tagto pin a specific version.
2. Full URL#
import "https://example.com/path/to/module.wren" for ClassName
You must include the
.wrenextension in the URL.The URL must return raw Wren source code, not HTML.
For GitHub, use
raw.githubusercontent.comURLs.
import "https://raw.githubusercontent.com/4lb0/emoji/main/emoji.wren" for Emoji
How It Works#
Cache check — Bialet checks whether the module is already cached in the
BIALET_REMOTE_MODULESdatabase table.Download — if not cached, it downloads the module via HTTP GET.
Validation — checks for a 2xx status code.
Store — the content is stored in the database, keyed by import path.
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
mainbranch 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 |
|---|---|
|
|
|
|
The
.wrenextension is added automatically.Default branch is
mainif 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, notmaster.
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://, orhttps://only — noftp://, nofile://.Check for typos in the import statement.
Performance Tips#
Use version tags to avoid unnecessary cache invalidation.
Import only what you need — every import adds to first-load time.
Pre-cache modules in development before deploying.
Monitor cache size if you import many large modules.
Key Takeaways#
Query parameters are the default — any
.wrenfile reads dynamic data withRequest.get(name); no special file or extra route needed.File-based routing — every
.wrenfile 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.wrenplusRequest.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 imports —
gh:shorthand or full URLs, cached inBIALET_REMOTE_MODULES, never auto-updating once cached.