Advanced 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.
Dynamic data comes from two equally simple places: query strings
(article.wren reading ?id=42 with Request.get("id")) or path
segments (blog.wren reading /blog/my-post with Request.route(0)).
Both are first-class — pick whichever fits the URL you want. What Bialet
discourages is complex routing: deep nesting and convoluted URL
hierarchies. Keep it simple.
There is no router to configure, no app.get(...) calls, no urlpatterns
list. The layout of your project is the routing table — you put a file
where you want a URL, and the URL exists.
Overview#
Here’s the entire mental model in one directory tree:
my-site/
├── index.wren # /
├── about.wren # /about
└── article.wren # /article/my-article (dynamic via path slug)
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 follows it in the path to decide what to render.
// File: article.wren
// Handles: /article/my-article
var slug = Request.route(0)
var article = `SELECT title, body_html FROM articles WHERE slug = ?`.first(slug)
if (!article) return Response.notFound()
return <main>
<h1>{{ article["title"]}}
{{ article["body_html"].raw }}
</main>
No new file, no special name — the same file serves /article and every
/article/<slug> URL via Request.route(0).
⚠️ Pitfall:
/about.wrenand/aboutboth serveabout.wren, but the.wrenextension is never required in links you write. Always link to the extension-less form.
Both styles are equal. A dynamic value can live in the query string (
/article?id=42) or in the path (/article/my-postvia a<folder>.wrenfile) — Bialet treats them the same. Pick whichever produces the URL you want; the path form is covered in Path-Based Dynamic Routes.
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, see Dynamic Content with Query Parameters and Path-Based Dynamic Routes below — pick whichever gives you the URL you want, and keep it shallow.
How a Request Is Resolved#
When a request arrives, Bialet resolves the URL in this order:
Exact file match – if
<full_path>.wrenexists, execute it.Folder-file walk-up – walk up the directory tree, checking each parent folder for a
<folder>.wrenfile. The deepest match (closest to the URL) wins.404 Not Found – if no file is found.
Example: /some/lorem/ipsum
If
/some/lorem/ipsum.wrenexists → execute it (no dynamic segments).Else if
/some/lorem.wrenexists → execute it;Request.route(0)="ipsum".Else if
/some.wrenexists → execute it;Request.route(0)="lorem",Request.route(1)="ipsum".Else → 404.
Protected folders (
_or.) are never considered during the walk-up — they return 403 immediately. The one exception is.well-known, which is walked into like any other folder (see The.well-knownexception).
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
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.
The .well-known exception#
Every dot-prefixed path is protected except one: a URL whose first
segment is exactly .well-known. The whole subtree below it is served
normally — static files, .wren routes, nested folders, and even dotfiles:
.well-known/openid-configuration # /.well-known/openid-configuration
.well-known/security.txt # /.well-known/security.txt
.well-known/acme-challenge/<token> # /.well-known/acme-challenge/<token>
.well-known/oauth-authorization-server.wren # /.well-known/oauth-authorization-server
.well-known/sub/folder/file # /.well-known/sub/folder/file
The namespace is public by design (RFC 8615): ACME/Let’s Encrypt challenges,
OAuth 2.0 and OpenID Connect metadata, WebFinger, Android/iOS app links,
Apple Pay, MTA-STS, and security.txt all live there. Everywhere else,
dotfiles stay protected because they leak secrets and config (.env,
.git/, .htaccess, .DS_Store).
The rule is deliberately narrow:
Path-prefix, first segment only.
/.well-known/xis allowed, but/foo/.well-known/xis not — there.well-knownis just another dotfolder.Case-sensitive.
.Well-Knownis not exempt; RFC 8615 matches the literal lowercase spelling.No decoding. Bialet matches the raw path. Percent-encoded forms such as
/%2ewell-known/xare not treated as the exempt prefix; they simply don’t resolve.Traversal still blocked.
/.well-known/../.envis a 403, and a symlink inside.well-knownthat points at a protected file is rejected after resolution.
⚠️ Pitfall:
.well-knownonly counts as the first URL segment. If you mount your app under a prefix at the proxy, strip that prefix before forwarding — a request that arrives as/prefix/.well-known/...gets a 403.
How to add a well-known resource#
Drop the file (or .wren route) under .well-known/ at your project root.
To serve /.well-known/security.txt, create:
.well-known/security.txt
Contact: mailto:security@example.com
Expires: 2027-01-01T00:00:00.000Z
No route and no configuration — the file path is the URL, exactly like the
rest of the tree. A .wren route works too: .well-known/token.wren serves
/.well-known/token.
JSON caveat: the default ignore list names
*.json,*.yml, and*.yaml, but.well-knownresources are served like any other file, so JSON metadata (assetlinks.json,jwks.json,did.json,apple-app-site-association) works. Keep secrets out of.well-knownregardless — the namespace is public.
Dynamic Content with Query Parameters#
This is one of the two simple ways to make a page dynamic in Bialet. 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.
// File: blog.wren
// Handles: /blog?id=42
import "_app/template" for Template
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 Template.new().layout(<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 over-engineer either style. A single dynamic value can live in a query string or a path segment — both are equally fine. What Bialet discourages is complex routing: deep nesting, many segments, or elaborate URL hierarchies. Keep dynamic URLs one or two levels deep.
Path-Based Dynamic Routes#
Path-based routing is a first-class option, equal to query parameters. Use it when the dynamic value reads naturally as part of the path — human-readable slugs (
/blog/how-to-cook-rice), REST-style resources (/api/users/123), or a URL structure you inherited. Like query strings, keep it shallow — see How Deep Should Your Dynamic Routes Go?.
Query string or path segment?#
Both are simple, first-class options — pick by how the URL should read:
Query string (
Request.get) – dynamic data that feels like a variable:/article?id=42,/search?q=hello,/api/users?id=7.Path segment (
<folder>.wren+Request.route(n)) – dynamic data that reads as part of the URL itself:/blog/my-article,/api/users/123.
What Bialet discourages is complex routing: deeply nested paths, many segments, or elaborate URL schemes. Keep either style shallow — see How Deep Should Your Dynamic Routes Go?.
How it works#
Fixed files can’t cover URLs with variable path segments. For those URLs,
name a .wren file after a folder: <folder>.wren. When a URL
doesn’t resolve to a static file, Bialet walks up the path looking for a
.wren file named after each segment, deepest first. /api/users/123
matches api.wren, /blog/how-to-cook-rice matches blog.wren, and a
file that doesn’t exist gets a 404.
// File: api.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<folder>.wrenfile’s own URL. At the bare folder URL itself (/api),Request.route(0)isnull.
URL |
Matching file (if any) |
|
|
|
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
not used (exact match) |
not used |
not used |
Request.route(0)is the first segment after the<folder>.wrenfile’s own URL.If the request matches an exact file,
Request.route(n)always returnsnull.
A single <folder>.wren therefore covers a whole resource: the bare URL
(Request.route(0) is null) renders the list, and every deeper path
renders an item. This is the classic REST-style list + detail pattern in
one file.
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, so the two aren’t mutually exclusive —
you’re just choosing where the primary identifier lives.
You don’t need one global route file for the whole site. Create a
separate <folder>.wren for each folder that needs path-based handling —
blog.wren, admin/posts.wren, api.wren can all coexist and handle
their own subtree independently. The deepest match wins, so admin.wren
and admin/posts.wren can coexist: /admin/posts/456 runs
admin/posts.wren, anything else under /admin runs admin.wren.
When a folder has both an index.wren and a <folder>.wren, the
<folder>.wren wins for the folder URL itself — Bialet probes the .wren
file before the directory index.
Complete Example Project Structure#
A blog application using the _app/ folder approach. Query parameters are
the dynamic-content strategy here, but path-based routes would work just
as well:
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.wren # Post list + edit (/admin/posts, /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<folder>.wrenfor path-based URLs —blog.wrenfor/blog/my-first-post,api.wrenfor/api/posts/456. The choice is yours; see Path-Based Dynamic Routes.
URL |
File Executed |
Purpose |
|---|---|---|
|
|
Homepage |
|
|
About page |
|
|
Blog post list |
|
|
Single post, looked up by |
|
|
Admin dashboard |
|
|
Post list |
|
|
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 Template
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 the shared template
return Template.new().layout(<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 Template
⚠️ 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, mark it safe withHtmlNodeor.rawonly if you trust it —{{ }}escapes plain strings automatically. 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#
Use this when /blog/my-first-post reads better in the address bar than
/article?id=42. It requires a slug column and a <folder>.wren file —
a small cost, and the choice is yours: pick whichever URL you prefer.
File: blog.wren
import "/_app/template" for Template
var slug = Request.route(0)
if (!slug) {
var posts = `SELECT title, slug FROM posts WHERE published = 1 ORDER BY createdAt DESC`.fetch
return Template.new().layout(<main>
<h1>Blog</h1>
<ul>{{ posts.map{|p| <li><a href="/blog/{{ p["slug"] }}">{{ p["title"] }}</a></li>} }}</ul>
</main>)
}
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 Template.new().layout(<article>
<h1>{{post["title"]}}</h1>
<p class="meta">By {{post["author"]}} on {{post["createdAt"]}}</p>
<div class="content">
{{post["content"]}}
</div>
</article>)
blog.wren now serves both /blog (the list, when Request.route(0) is
null) and /blog/my-first-post (a single post).
⚠️ Pitfall: this version needs a dedicated
slugcolumn. Trade it for a path-based URL only if the URL shape matters to you — see Path-Based Dynamic Routes.
How Deep Should Your Dynamic Routes Go?#
Bialet supports any depth – the filesystem is the only limit. However, for practical maintainability:
1–2 levels – covers 90% of use cases (e.g.,
/category/<slug>,/api/<resource>/<id>).3 levels – rare but acceptable (e.g.,
/api/<version>/<resource>/<id>).4+ levels – usually a sign that you’re over-encoding data in the path. Consider using query parameters instead.
There is no performance penalty for deeper routes – the walk-up is a few filesystem checks, negligible compared to rendering or database queries. But deep URLs are harder for users and search engines, and often indicate a design smell.
Common Pitfalls#
route(0)is not the first segment of the URL – it’s the first segment after the<folder>.wrenfile’s own URL. For/api/users/123handled byapi.wren,route(0)is"users", not"api".If both
some/index.wrenandsome.wrenexist – the<folder>.wren(some.wren) wins for/some. This is because Bialet checks for.wrenfiles before directory indexes.Protected folders are never matched – a
<folder>.wreninside_app/will never be reached because_app/is blocked entirely.Exact matches always win – if
/blog/my-post.wrenexists, it will be executed for/blog/my-posteven ifblog.wrenalso exists. The walk-up stops at the exact match.Query parameters are still available – you can use both
Request.get(...)andRequest.route(n)in the same file.
Routing Compared: Bialet vs Express vs Django#
Framework |
Route declared by |
Dynamic value from |
Typical default |
|---|---|---|---|
Bialet (query string) |
file path |
|
query string |
Bialet (path-based) |
file path + |
|
path segment |
Express |
|
|
path segment |
Django |
|
|
path segment |
Key insight: Bialet treats query strings and path segments as equal — pick whichever reads best in the URL. Express and Django put dynamic values in the path by default; Bialet leaves the choice to you, and discourages complex routing either way.
External Imports#
Bialet supports importing external Wren modules from remote sources —
gh:owner/repo/path shorthand or a full URL — so you can use
community-created libraries without manually downloading and managing
them:
import "gh:4lb0/emoji/emoji@1.0" for Emoji
See External Modules for the full import syntax,
how the download/cache cycle works, how to author and publish your own
module (including the relative-import restriction inside remote code),
and how to clear or programmatically refresh the BIALET_REMOTE_MODULES
cache.
Key Takeaways#
Simple routing either way – query strings (
Request.get) and path segments (<folder>.wren+Request.route(n)) are equal, first-class options. Pick whichever reads best in the URL.File-based routing – every
.wrenfile is a route; the filesystem is the routing table.Protected files (
_or.) – never served directly, and never considered in the walk-up.Complex routing is discouraged – keep dynamic URLs 1–2 levels deep; deep nesting and elaborate schemes are a design smell.
No route table – nothing to register, nothing to keep in sync.
External imports – use
gh:or full URLs for remote modules (cached locally).