Facet Developer's Guide — SSR Framework Reference
Complete reference for Facet's features and capabilities
Facet is a data-driven web framework that transforms JSON documents into server-rendered HTML through path-based templates. It is a RESTHeart plugin that provides a hybrid API/UI from the same endpoint — JSON for API clients, HTML for browsers.
Core principle: Convention over Configuration. Your template structure mirrors your API paths. A request to /mydb/products automatically uses templates/mydb/products/list.html or falls back to index.html when the browser requests HTML.
Technology stack:
- RESTHeart — MongoDB REST API server (plugin architecture, HTTP layer, auth)
- Pebble — Template engine with Jinja2-like syntax
- GraalVM — JDK with JavaScript interop for plugins
Table of Contents
- Quick Start with Docker
- Understanding the SSR Framework
- Template Structure & Conventions
- Authentication & Security
- Tutorial: Creating Your First Application
- Tutorial: Building Custom SSR Applications
- Tutorial: Selective SSR
- Template Context Variables
- Working with HTMX
- CRUD Operations with HTMX Fragments
- Handling Mutations
- Advanced Patterns
- JavaScript Plugins
- Configuration Reference
- Best Practices
- Troubleshooting
Quick Start with Docker
git clone https://github.com/SoftInstigate/facet.git
cd facet
docker compose up
Open http://localhost:8080/, log in with admin / secret, then visit /mydb/products to see seeded data rendered by the default template.
Understanding the SSR Framework
How It Works
The SSR framework automatically transforms REST API JSON responses into HTML using path-based template resolution:
Request: GET /mydb/products
Accept: text/html
1. MongoService returns MongoDB data
2. HtmlResponseInterceptor intercepts
3. PathBasedTemplateResolver finds template
4. Pebble renders HTML
5. Browser receives HTML page
Template Resolution Algorithm
Collection requests (e.g., GET /mydb/products):
Search order:
1. templates/mydb/products/list.html <- recommended (explicit collection view)
2. templates/mydb/products/index.html (optional fallback)
3. templates/mydb/list.html (parent fallback)
4. templates/mydb/index.html (parent fallback)
5. templates/list.html (global collection template)
6. templates/index.html (global fallback)
7. No template -> return JSON unchanged
Document requests (e.g., GET /mydb/products/123):
Search order:
1. templates/mydb/products/123/view.html (document-specific override)
2. templates/mydb/products/view.html <- recommended (explicit document view)
3. templates/mydb/products/index.html (optional fallback)
4. templates/mydb/view.html (parent fallback)
5. templates/mydb/index.html (parent fallback)
6. templates/view.html (global document template)
7. templates/index.html (global fallback)
8. No template -> return JSON unchanged
HTMX fragment requests (requests with HX-Target header):
GET /mydb/products
HX-Request: true
HX-Target: #product-list
1. templates/mydb/products/_fragments/product-list.html (resource-specific)
2. templates/_fragments/product-list.html (root fallback)
3. No template -> 500 error (strict mode surfaces issues early)
Key behaviors:
- Full-page: SSR is opt-in. No template = JSON returned unchanged
- Fragment: strict mode. Missing fragment = 500 error
- Full page templates walk up the directory tree; fragment templates do not
Template Naming Convention
| Name | Purpose |
|---|---|
list.html |
Collection view — explicit, preferred |
view.html |
Document view — explicit, preferred |
index.html |
Shared fallback when list/view logic is identical |
_fragments/ |
HTMX entry points (underscore = not URL-accessible) |
layout.html |
Base layout for Pebble inheritance |
Template Structure & Conventions
Recommended Directory Layout
templates/
├── layout.html # Main layout (Pebble extends)
├── list.html # Root list (databases)
├── error.html # Global error page
├── _fragments/ # Shared HTMX fragments
│ └── my-fragment.html
└── mydb/
├── list.html # Collections list for 'mydb'
└── products/
├── list.html # Products collection
├── view.html # Single product
└── _fragments/
└── product-list.html # HTMX partial for products
favicon.ico — Prevent Auth Popup
Browsers auto-request /favicon.ico. If auth is required it causes an unwanted popup. Fix:
/static-resources:
- what: /static/favicon.ico
where: /favicon.ico
embedded: true
Authentication & Security
Default Credentials
RESTHeart includes a default admin user for development:
- Username:
admin— Password:secret
⚠️ Change this password before deploying to production.
File-Based Authentication (Recommended for Examples)
users.yml — credentials and role assignments:
users:
- userid: admin
password: secret
roles: [admin]
- userid: viewer
password: viewer
roles: [viewer]
restheart.yml — role-based permissions:
/fileAclAuthorizer:
enabled: true
permissions:
- role: admin
predicate: path-prefix[path=/]
mongo:
readFilter: null
writeFilter: null # Full read/write
- role: viewer
predicate: path-prefix[path=/]
mongo:
readFilter: null
writeFilter: '{"_id": {"$exists": false}}' # Read-only
Requires a restart to apply changes. For dynamic user management, see RESTHeart MongoDB-based auth.
Template Context: Auth Variables
{% if isAuthenticated %}
<p>Logged in as: {{ username }}</p>
<p>Roles: {{ roles | join(", ") }}</p>
<a href="{{ loginUrl }}?logout">Logout</a>
{% else %}
<a href="{{ loginUrl }}">Login</a>
{% endif %}
{% if roles and 'admin' in roles %}
<button>+ Add Product</button>
{% endif %}
Tutorial: Creating Your First Application
Step 1: Create the Layout
<!-- templates/layout.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}My Application{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
</head>
<body>
<nav>
<a href="/">My App</a>
{% if username %}<span>{{ username }}</span>{% endif %}
</nav>
<main class="container">
{% block main %}{% endblock %}
</main>
</body>
</html>
Step 2: Create the Collection List Template
<!-- templates/mydb/products/list.html -->
{% extends "layout" %}
{% block title %}Products{% endblock %}
{% block main %}
<h1>Products ({{ totalDocuments }} total)</h1>
{% if documents %}
<table>
<thead>
<tr><th>Name</th><th>Price</th><th>Actions</th></tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td>{{ doc.name }}</td>
<td>${{ doc.price }}</td>
<td><a href="{{ path }}/{{ doc._id }}">View</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% if totalPages > 1 %}
<nav>
{% if page > 1 %}
<a href="?page={{ page - 1 }}&pagesize={{ pagesize }}">Previous</a>
{% endif %}
<span>Page {{ page }} of {{ totalPages }}</span>
{% if page < totalPages %}
<a href="?page={{ page + 1 }}&pagesize={{ pagesize }}">Next</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<p>No products found.</p>
{% endif %}
{% endblock %}
Step 3: Create the Document View Template
<!-- templates/mydb/products/view.html -->
{% extends "layout" %}
{% block title %}{{ documents[0].name }}{% endblock %}
{% block main %}
{% if documents %}
{% set product = documents[0] %}
<article>
<h1>{{ product.name }}</h1>
<p class="price">${{ product.price }}</p>
<p>{{ product.description }}</p>
<a href="{{ path | parentPath }}">Back to Products</a>
</article>
{% endif %}
{% endblock %}
Step 4: Create an Error Page
<!-- templates/error.html -->
{% extends "layout" %}
{% block title %}Error {{ statusCode }}{% endblock %}
{% block main %}
<div class="alert">
<h1>{{ statusCode }} — {{ statusMessage }}</h1>
<p>Path: <code>{{ path }}</code></p>
<a href="/">Go Home</a>
</div>
{% endblock %}
Tutorial: Building Custom SSR Applications
Scoped Layouts per Area
Each section of your app can use its own layout:
templates/
├── layout.html # Global layout
├── admin/
│ ├── _layouts/
│ │ └── layout.html # Admin layout with sidebar
│ └── users/
│ └── list.html # Uses admin layout
└── ping/
└── index.html # Uses minimal layout
Admin layout:
<!-- templates/admin/_layouts/layout.html -->
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}Admin{% endblock %}</title>
</head>
<body>
<nav class="sidebar">
<a href="/admin/users">Users</a>
<a href="/admin/settings">Settings</a>
</nav>
<main>{% block main %}{% endblock %}</main>
</body>
</html>
Admin users template:
<!-- templates/admin/users/list.html -->
{% extends "admin/_layouts/layout" %}
{% block title %}User Management{% endblock %}
{% block main %}
<h1>Users</h1>
<table>
{% for doc in documents %}
<tr>
<td>{{ doc.username }}</td>
<td>{{ doc.email }}</td>
<td>
{% for role in doc.roles %}
<span class="badge">{{ role }}</span>
{% endfor %}
</td>
</tr>
{% endfor %}
</table>
{% endblock %}
Tutorial: Selective SSR
SSR is opt-in per resource. If no template exists, the API returns JSON unchanged.
templates/
└── shop/
└── products/
└── list.html <- only products get HTML
GET /shop/products -> HTML (template exists)
GET /shop/orders -> JSON (no template)
GET /api/* -> JSON (no templates at all)
Content Negotiation
The same endpoint serves both formats:
# HTML (browser default)
curl -H "Accept: text/html" http://localhost:8080/shop/products
# JSON (API client)
curl -H "Accept: application/json" http://localhost:8080/shop/products
Template Context Variables
Facet injects rich context into every template. See the Template Context Reference for a complete list.
Quick Reference
| Variable | Type | Description |
|---|---|---|
documents |
List | Array of MongoDB documents |
database |
String | Database name |
collection |
String | Collection name |
path |
String | Full request path |
page |
Integer | Current page number |
pagesize |
Integer | Items per page |
totalPages |
Integer | Total number of pages |
totalDocuments |
Long | Total document count |
isAuthenticated |
Boolean | Whether user is logged in |
username |
String | Authenticated user name |
roles |
Set<String> | User's roles |
requestMethod |
String | HTTP method (GET, POST, PATCH, …) |
filter |
String | MongoDB filter query |
sort |
String | MongoDB sort spec |
keys |
String | Field projection |
Custom Pebble Filters
Facet adds two path-manipulation filters:
{# Strip trailing slash #}
{{ "/shop/products/" | stripTrailingSlash }} {# -> "/shop/products" #}
{# Append segment safely #}
{{ "/shop" | buildPath("products") }} {# -> "/shop/products" #}
Use these to build navigation links that work with any MongoDB mount prefix.
Working with HTMX
Declarative HTMX (in Templates)
Control HTMX behavior directly in templates — no Java changes needed:
<!-- Partial update -->
<a href="/shop/products?sort=price"
hx-get="/shop/products?sort=price"
hx-target="#product-list"
hx-swap="innerHTML"
hx-push-url="true">
Sort by price
</a>
<!-- Inline form submission -->
<form hx-post="/shop/products"
hx-target="#product-list"
hx-swap="beforeend">
<input name="name" required>
<button type="submit">Add</button>
</form>
HTMX Fragment Templates
When HTMX sends HX-Request: true and HX-Target: #product-list, Facet looks for a fragment template matching the target name:
HX-Target: #product-list
-> templates/shop/products/_fragments/product-list.html (resource-specific)
-> templates/_fragments/product-list.html (root fallback)
Fragment templates contain only the partial HTML (no <html>, <head>, <body>):
<!-- templates/_fragments/product-list.html -->
<div id="product-list">
{% for doc in documents %}
<article>
<h3>{{ doc.name }}</h3>
<p>${{ doc.price }}</p>
</article>
{% endfor %}
</div>
Server-Side HTMX Control
For custom Java services that need to control client behavior from the server:
// Trigger a client-side event after DOM swap
HtmxResponseHelper.triggerEventAfterSwap(response, "productSaved", details);
// Redirect client
HtmxResponseHelper.redirect(response, "/shop/products");
// Retarget the swap element
HtmxResponseHelper.retarget(response, "#error-notifications");
// Force full-page refresh
HtmxResponseHelper.refresh(response);
// Push URL to browser history
HtmxResponseHelper.pushUrl(response, "/shop/products");
For template-only customizations, use declarative HTMX attributes.
HtmxResponseHelperis for custom Java services.
Handling Server-Triggered Events (Client-Side)
Listen to events triggered by HtmxResponseHelper.triggerEventAfterSwap():
document.addEventListener('productSaved', (event) => {
const { id, name } = event.detail;
showToast(`Saved: ${name}`);
});
Event timing:
triggerEvent()— fires immediately (before DOM swap)triggerEventAfterSwap()— fires after content is in DOM (most common)triggerEventAfterSettle()— fires after CSS animations complete
CRUD Operations with HTMX Fragments
The pattern: URLs represent REST resources. Forms load on demand as HTMX fragments. HTTP methods are the actions.
View Product (GET)
<!-- templates/shop/products/view.html -->
{% extends "layout" %}
{% block main %}
{% if documents %}
{% set product = documents[0] %}
<article>
<h1>{{ product.name }}</h1>
<p>${{ product.price }}</p>
<p>{{ product.description }}</p>
<button hx-get="{{ path }}"
hx-target="#product-form"
hx-swap="innerHTML">
Edit
</button>
</article>
<div id="product-form"></div>
{% endif %}
{% endblock %}
Edit Product Fragment (PATCH)
Loaded dynamically via HTMX when "Edit" is clicked:
<!-- templates/_fragments/product-form.html -->
<article>
<h2>Edit Product</h2>
<form id="productEditForm">
<label>Name
<input type="text" name="name" value="{{ documents[0].name }}" required>
</label>
<label>Price
<input type="number" name="price" value="{{ documents[0].price }}" step="0.01" required>
</label>
<button type="submit">Save</button>
</form>
</article>
<script>
document.getElementById('productEditForm').addEventListener('submit', async function(e) {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
data.price = parseFloat(data.price);
const response = await fetch('{{ path }}', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data)
});
if (response.ok) window.location.reload();
});
</script>
Create Product (POST)
From the list page, "+ Add Product" loads a creation fragment:
<!-- In list.html -->
<button hx-get="{{ path }}"
hx-target="#product-new"
hx-swap="innerHTML">
+ Add Product
</button>
<div id="product-new"></div>
<!-- templates/_fragments/product-new.html -->
<form id="productNewForm">
<label>Name <input name="name" required></label>
<label>Price <input name="price" type="number" step="0.01" required></label>
<button type="submit">Create</button>
</form>
<script>
document.getElementById('productNewForm').addEventListener('submit', async function(e) {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
data.price = parseFloat(data.price);
const response = await fetch('{{ path }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data)
});
if (response.ok) window.location.reload();
});
</script>
Delete Product (DELETE)
<button onclick="deleteProduct()">Delete</button>
<script>
async function deleteProduct() {
if (!confirm('Delete this product?')) return;
const response = await fetch('{{ path }}', {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) window.location.href = '{{ path | parentPath }}';
}
</script>
Pattern Benefits
- Clean URLs:
/products/123not/products/123/edit - No routing conflicts: document
_id: "edit"is unambiguous - Progressive enhancement: works with/without JavaScript
- Reusable fragments: same
product-form.htmlfrom list and detail pages
Handling Mutations
Three patterns for responding to write operations:
Pattern 1: HTMX Fragment Swap (Recommended for SPAs)
After a mutation, return only the updated HTML fragment. The requestMethod context variable lets the same fragment render differently for GET vs POST:
<!-- templates/shop/products/_fragments/product-row.html -->
{% if requestMethod == "POST" %}
<tr>
<td>{{ documents[0].name }}</td>
<td>{{ documents[0].price }}</td>
<td><span class="badge">Created</span></td>
</tr>
{% else %}
<tr>
<td>{{ documents[0].name }}</td>
<td>{{ documents[0].price }}</td>
</tr>
{% endif %}
Form that triggers the fragment swap:
<form hx-post="/shop/products"
hx-target="#product-list tbody"
hx-swap="afterbegin">
<input name="name" required>
<input name="price" type="number" step="0.01" required>
<button type="submit">Add Product</button>
</form>
Pattern 2: Post-Redirect-Get (PRG)
For non-HTMX forms or when you need the browser address bar to update. RESTHeart returns 201 Created with a Location header; the browser follows it automatically to a clean GET.
This avoids the "Confirm Form Resubmission" dialog on page refresh.
Pattern 3: Inline Response with requestMethod
Show a success message on the same page after a write:
<!-- templates/shop/products/view.html -->
{% if requestMethod == "POST" %}
<div class="alert success">Product created!</div>
{% elseif requestMethod == "PATCH" %}
<div class="alert info">Product updated.</div>
{% endif %}
<article>
<h1>{{ documents[0].name }}</h1>
<p>${{ documents[0].price }}</p>
</article>
Available requestMethod values: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, OTHER
Choosing the Right Pattern
| Scenario | Recommended Pattern |
|---|---|
| SPA / HTMX-powered page | HTMX Fragment Swap |
| Multi-page app, SEO URLs | Post-Redirect-Get |
| Simple admin forms | Inline requestMethod check |
| File uploads | Post-Redirect-Get |
Advanced Patterns
Template Composition with include
Break large templates into smaller reusable parts:
<!-- templates/shop/products/list.html -->
{% extends "layout" %}
{% block main %}
{% include "shop/products/filters" %}
<div class="products">
{% for doc in documents %}
{% include "shop/products/product-card" with {'product': doc} %}
{% endfor %}
</div>
{% endblock %}
<!-- templates/shop/products/product-card.html -->
<div class="product-card">
<h3>{{ product.name }}</h3>
<span class="price">${{ product.price }}</span>
</div>
Client-Side Enhancement
Hydrate server-rendered HTML with client-side behavior using Alpine.js, React, Vue, or plain JS:
<div x-data="{
products: {{ documents | json_encode }},
filter: '',
get filtered() {
return this.products.filter(p => p.name.toLowerCase().includes(this.filter.toLowerCase()));
}
}">
<input type="text" x-model="filter" placeholder="Filter...">
<template x-for="p in filtered" :key="p._id">
<div>
<h3 x-text="p.name"></h3>
<span x-text="'$' + p.price"></span>
</div>
</template>
</div>
Conditional Template Logic
{% if totalDocuments == 0 %}
<div class="empty-state">No products found.</div>
{% elseif totalDocuments > 1000 %}
<p>Large dataset. Use filters to narrow results.</p>
{% include "shop/products/list-compact" %}
{% else %}
{% include "shop/products/list-full" %}
{% endif %}
HTMX Polling
<!-- Poll every 5 seconds -->
<div hx-get="/api/status"
hx-trigger="every 5s"
hx-swap="innerHTML">
Status: Loading...
</div>
JavaScript Plugins
RESTHeart runs on GraalVM, enabling server-side plugins in JavaScript with no compilation step. JavaScript plugins are hot-reloaded exactly like Pebble templates — save and the next request picks up the change.
Why JavaScript Plugins?
| Java Plugin | JavaScript Plugin | |
|---|---|---|
| Compilation | mvn package + restart |
None |
| Hot reload | No | Yes |
| MongoDB access | @Inject("mclient") |
mclient global |
| Performance | Highest | Excellent (GraalVM JIT) |
Writing a Service Plugin
A minimal service needs two exports in a .mjs file:
export const options = {
name: "productStatsService",
description: "Aggregated product statistics",
uri: "/shop/stats",
secured: true,
matchPolicy: "EXACT"
};
export function handle(request, response) {
const BsonDocument = Java.type("org.bson.BsonDocument");
const db = mclient.getDatabase("shop");
const coll = db.getCollection("products", BsonDocument.class);
let total = 0, inStock = 0, totalValue = 0;
coll.find().forEach(doc => {
total++;
const price = doc.containsKey("price") ? doc.getNumber("price").doubleValue() : 0;
const stock = doc.containsKey("stock") ? doc.getNumber("stock").intValue() : 0;
if (stock > 0) inStock++;
totalValue += price * stock;
});
response.setContent(JSON.stringify({ total, inStock, totalValue }));
response.setContentTypeAsJson();
}
BSON accessor methods:
| Type | Method | Returns |
|---|---|---|
| String | doc.getString("key").getValue() |
String |
| Number | doc.getNumber("key").doubleValue() |
double |
| Integer | doc.getNumber("key").intValue() |
int |
| Boolean | doc.getBoolean("key").getValue() |
boolean |
| Key check | doc.containsKey("key") |
boolean |
Use LOGGER.info("value: {}", someValue) for structured logging.
Plugin Manifest (package.json)
{
"name": "my-plugin",
"version": "1.0.0",
"rh:services": ["my-service.mjs"],
"rh:interceptors": []
}
Files not listed are ignored by RESTHeart.
Deploying via Docker Compose
volumes:
# Mount each plugin folder individually
# (preserves existing JAR plugins in /opt/restheart/plugins/)
- ./plugins/my-plugin:/opt/restheart/plugins/my-plugin:ro
Rendering Plugin Output as HTML
Facet's HtmlResponseInterceptor intercepts responses from any service, not just MongoDB. For JavaScript services, every top-level JSON field becomes a Pebble template variable:
The plugin returns:
{ "total": 10, "avgPrice": 309.49, "categories": [...] }
The template receives total, avgPrice, categories directly as variables:
<p>{{ total }} products</p>
<p>Average: ${{ avgPrice | numberformat("#,##0.00") }}</p>
{% for cat in categories %}
<li>{{ cat.name }}: {{ cat.count }}</li>
{% endfor %}
Template resolution for non-MongoDB services uses index.html only:
GET /shop/stats
-> templates/shop/stats/index.html <- place your template here
-> templates/shop/index.html (parent fallback)
-> templates/index.html (root fallback)
Working Example
The product catalog ships a complete example:
| File | Purpose |
|---|---|
plugins/product-stats/product-stats.mjs |
Service — queries MongoDB, returns stats JSON |
plugins/product-stats/package.json |
Plugin manifest |
templates/shop/stats/index.html |
Template — renders stats dashboard |
# JSON (API client)
curl -u admin:secret http://localhost:8080/shop/stats
# HTML (browser / Facet template)
curl -u admin:secret -H "Accept: text/html" http://localhost:8080/shop/stats
For complete reference including TypeScript, interceptor plugins, and npm module support: RESTHeart JavaScript Plugins.
Configuration Reference
Pebble Template Processor
/pebble-template-processor:
enabled: true
use-file-loader: true # Load from filesystem (enables hot-reload)
templates-path: /opt/templates/ # Custom templates location
cache-active: false # Disable caching for development
locale: en-US
HTML Response Interceptor
/html-response-interceptor:
enabled: true
response-caching: true # ETag-based caching (use false in dev)
max-age: 300 # Cache-Control max-age in seconds (5 min)
Development settings:
/html-response-interceptor:
response-caching: false
max-age: 5
Best Practices
- Start simple — begin with
list.html+view.htmlper resource, add complexity as needed - Use explicit templates — prefer
list.html/view.htmloverindex.html(cleaner, no conditional logic) - Reuse with
include— extract repeated UI into shared partials - Scope layouts — each major section should have its own layout
- Test both formats — verify JSON API still works with
Accept: application/json - Progressive enhancement — add HTML gradually; keep JSON as the fallback
- Hot-reload in dev — set
use-file-loader: trueandcache-active: falsefor fast iteration - Fragment strict mode — missing fragments intentionally return 500 to surface issues early
Troubleshooting
Template Not Found (Returns JSON Instead of HTML)
Check that the template exists at the correct path:
Request: GET /mydb/products
Expected: templates/mydb/products/list.html
or: templates/mydb/products/index.html
or: templates/list.html
Verify Accept: text/html is sent (browsers do this by default).
Wrong Layout Applied
Check the extends directive in your template — it determines which layout file is loaded.
Template Changes Not Appearing
Set in restheart.yml:
/pebble-template-processor:
cache-active: false
use-file-loader: true
Variables Undefined in Template
Debug by printing the context. In Pebble you can iterate _context to see all variables available in the current request.
Authentication Popup on favicon.ico
See the favicon.ico section above.
Next Steps
- Template Context Reference — All available template variables
- Product Catalog Tutorial — Build a complete app step-by-step
- RESTHeart Documentation — Auth, WebSockets, Change Streams, GridFS
- Pebble Templates — Full template syntax reference
- HTMX Documentation — Advanced HTMX patterns