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.

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.

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>&copy; 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