File-based partials
The simplest include use case: "I have a footer.html file and I want it on every page." No endpoint, no framework, no build step — just a file on disk, resolved server-side.
<!-- bext-native -->
<bext-include src="/_partials/footer.html"></bext-include>
<!-- SSI-compatible -->
<!--#include virtual="/_partials/footer.html" -->
<!-- ESI -->
<esi:include src="/_partials/footer.html" />
All three read from the site's _partials/ directory, cache the content in memory, and invalidate the cache automatically when the file changes on disk.
The _partials/ directory#
By convention, HTML fragments live in {site_root}/_partials/. This directory is auto-discovered at server startup.
site/
├── _partials/
│ ├── footer.html
│ ├── header.html
│ ├── social-links.html
│ └── legal/
│ ├── privacy.html
│ └── terms.html
├── pages/
│ └── index.html
└── bext.config.toml
Reference them with absolute paths:
<bext-include src="/_partials/footer.html"></bext-include>
<bext-include src="/_partials/legal/privacy.html"></bext-include>
Resolution rules#
| src | Resolved to |
|---|---|
/_partials/X |
{site_root}/_partials/X |
SSI file="X" |
{document_root}/X (relative to the file being served) |
SSI virtual="/X" |
{site_root}/X (server-relative) |
/arbitrary/path.html |
{site_root}/arbitrary/path.html |
The _partials/ prefix is a convention, not a requirement — any .html (or other allowed extension) file under the site root is includable.
Adding a ttl attribute to a file partial skips the stat() call entirely — bext serves from cache for the full TTL without checking the file's modification time. Use this for ultra-hot paths, but remember that file edits won't propagate until the TTL expires or you purge the cache tag.
Mtime-based cache#
Files are cached by path + mtime:
First request to /_partials/footer.html
→ stat file → mtime = 1712000000
→ read file → "<p>...</p>"
→ cache entry: (path, content, mtime)
→ return content
Second request
→ cache has this path
→ stat file → mtime = 1712000000 (unchanged)
→ cache hit → return cached content (no file read)
You edit footer.html
→ file's mtime changes to 1712001234
Third request
→ cache has this path
→ stat file → mtime = 1712001234 (changed!)
→ re-read file
→ update cache with new content and mtime
→ return fresh content
The stat() syscall is ~1µs on Linux (mostly hits the kernel's inode cache). Serving a cached file partial is dominated by this cost, not the file read.
Dev mode: file watcher#
In development (bext dev), the file watcher monitors the site directory. Edits to _partials/ files trigger immediate cache invalidation — you don't even need the stat check; the watcher pushes invalidations to the resolver.
$ bext dev
[bext] watching site/
[bext] change: _partials/footer.html — invalidating include cache
Next request for that partial re-reads the file without any stat overhead.
Only files with extensions in allowed_extensions (default: html, htm, svg, txt) are served. Attempting to include a .env, .toml, or .json file is rejected with a warning — not silently served.
Path security#
File partials enforce strict path sanitization:
- Null bytes in the path: rejected.
..components: rejected (prevents/_partials/../../etc/passwd).- Symlinks outside the site root: rejected (symlink jail enforced via
canonicalize()). - Leading slash: treated as server-relative (site root).
<!-- All rejected -->
<bext-include src="/_partials/../../etc/passwd"></bext-include>
<bext-include src="/../../etc/passwd"></bext-include>
<bext-include src="/_partials/\0hidden"></bext-include>
Size and extension limits#
[include.files]
enabled = true
max_size = "1MB"
allowed_extensions = ["html", "htm", "svg", "txt"]
max_size: files larger than this are rejected with a warning. Includes are for fragments, not full pages.allowed_extensions: only files with these extensions are resolved. Prevents accidentally inlining config files, secrets, etc.
Default extensions: html, htm, svg, txt. Add md if you want Markdown includes, but bext won't transform the content — you'll see raw Markdown in the HTML.
Non-UTF-8 files#
File partials are treated as UTF-8 HTML. Files that aren't valid UTF-8 (binary files, non-UTF encodings) are rejected:
WARN file partial not UTF-8: /_partials/logo.png — skipping
For SVG logos, the file is valid UTF-8 (XML text), so it works fine as a file partial. For actual binary files, use <img>, not include.
Combining with TTL#
File partials use mtime for freshness by default — no TTL needed. But you can add one to skip the stat check entirely:
<bext-include src="/_partials/footer.html"
ttl="1h"
></bext-include>
With ttl="1h", bext doesn't even stat the file — it serves from cache for an hour regardless. Trade-off: file edits take up to an hour to propagate (unless explicitly purged via the cache purge API).
This is mainly useful for very hot paths where even the 1µs stat is worth skipping.
Combining with other attributes#
File partials work with every other include feature:
<bext-include src="/_partials/user-profile.html"
auth="required"
vary="role"
ttl="5m"
></bext-include>
File content is cached per role. Anonymous users see the fallback. Each authenticated role gets its own cached copy.
Nested includes#
A file partial can contain other includes:
<!-- _partials/footer.html -->
<footer>
<p>© 2026 Example</p>
<bext-include src="/_partials/social-links.html"></bext-include>
</footer>
When footer.html is resolved, bext continues scanning for nested includes. The security config caps nesting depth (default: 3) to prevent recursion:
[include.security]
max_depth = 3
A file that includes itself (directly or transitively) is safely bounded.
SSI migration#
If you're migrating from nginx with SSI, your existing <!--#include --> tags work unchanged:
<!-- works exactly as it did under nginx -->
<body>
<!--#include virtual="/includes/header.html" -->
<main>...</main>
<!--#include virtual="/includes/footer.html" -->
</body>
bext resolves both tags from the filesystem. Cache and mtime invalidation are automatic.
You also gain every other bext include feature: add ttl via config rules, use SSI alongside <bext-include>, layer auth or experiments via config.
See also#
- Overview — top-level include system
- Caching includes — pair with TTL for hot-path optimization
- SSI & ESI compatibility — migrating from nginx
- Migration from nginx — full nginx-to-bext migration guide