Facet Product Catalog Tutorial - Learn SSR by Example
Learn Facet concepts through a complete working application
This tutorial walks you through a complete, working product catalog application to teach Facet concepts through real code.
Learning by Exploring
Instead of typing everything from scratch, you will:
- Run the working example in 2 minutes
- Explore the code to understand how it works
- Make live changes and see instant results
All code is in
examples/product-catalog/in the GitHub repository — no need to recreate it.
Table of Contents
- Get Started in 2 Minutes
- Level 1: Understanding Path-Based Templates
- Level 2: Template Context Variables
- Level 3: Hierarchical Template Resolution
- Level 4: MongoDB Query Parameters
- Level 5: Pagination
- Level 6: HTMX Partial Updates
- Level 7: Authentication and Authorization
- Level 8: Static Assets
- Level 9: CRUD with HTMX Fragments
- Level 10: JavaScript Plugins
- Production Considerations
1. Get Started in 2 Minutes
Run the Example
# Clone the repository
git clone https://github.com/SoftInstigate/facet.git
cd facet
# Start everything with Docker Compose
cd examples/product-catalog
docker compose up
Wait for services to start (few seconds).
You should see a styled product catalog with laptops, headphones, and other electronics.
Default login credentials:
- Admin:
admin/secret(full CRUD access) - Viewer:
viewer/viewer(read-only)
What Just Happened?
Docker Compose started three services:
- MongoDB — database with sample products (loaded from
init-data.js) - RESTHeart — REST API server with Facet plugin
- Template hot-reload — changes to templates reflect immediately
Verify the Dual Interface
The same endpoint serves both HTML and JSON:
# Browser request → HTML
curl -u admin:secret http://localhost:8080/shop/products -H "Accept: text/html"
# API request → JSON
curl -u admin:secret http://localhost:8080/shop/products -H "Accept: application/json"
Key concept: Templates are opt-in. No template = JSON API unchanged.
2. Level 1: Understanding Path-Based Templates
The Core Concept
Request path = template path
When you visit /shop/products, Facet looks for a template at templates/shop/products/.
Explore the List Template
Open examples/product-catalog/templates/shop/products/list.html
Key sections to notice:
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ database | capitalize }} - Products</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>
Notice: {{ database }} is a template variable from Facet's context.
Product iteration using context variables:
{% for doc in documents %}
<article>
<h3>{{ doc.name }}</h3>
<p class="price">${{ doc.price }}</p>
</article>
{% endfor %}
The documents variable contains all products from MongoDB.
Try It Yourself
- Edit the template — change
<h3>{{ doc.name }}</h3>to<h3>🛍️ {{ doc.name }}</h3> - Refresh your browser — see the emoji appear instantly (hot reload)
- Revert the change
Template Naming Convention
Facet uses explicit action-aware resolution:
- Collection requests → looks for
list.htmlfirst, thenindex.html(fallback) - Document requests → looks for
view.htmlfirst, thenindex.html(fallback)
Use list.html and view.html explicitly to keep each template focused and simple.
3. Level 2: Template Context Variables
What Variables Are Available?
Facet automatically provides rich context variables to every template. From the list template:
<header>
<h1>{{ database | capitalize }} - Products Catalog</h1>
<p>Showing {{ documents | length }} products</p>
</header>
Variables used:
database— database name from URL path ("shop")documents— array of product documents from MongoDB| capitalizeand| length— built-in Pebble filters
Most Commonly Used Variables
| Variable | Description | Example |
|---|---|---|
documents |
Array of MongoDB documents | {% for doc in documents %} |
database |
Database name | "shop" |
collection |
Collection name | "products" |
path |
Full request path | "/shop/products" |
page |
Current page number | 1 |
pagesize |
Items per page | 25 |
totalPages |
Total page count | 4 |
See the Template Context Reference for the complete list.
Try It Yourself
Add this before the closing </main> tag in the list template to see all variables:
<details>
<summary>Debug: Context Variables</summary>
<ul>
<li><strong>path:</strong> {{ path }}</li>
<li><strong>database:</strong> {{ database }}</li>
<li><strong>collection:</strong> {{ collection }}</li>
<li><strong>requestType:</strong> {{ requestType }}</li>
<li><strong>page:</strong> {{ page }}</li>
<li><strong>totalPages:</strong> {{ totalPages }}</li>
<li><strong>totalDocuments:</strong> {{ totalDocuments }}</li>
</ul>
</details>
Refresh and expand the "Debug: Context Variables" section. Remove when done.
4. Level 3: Hierarchical Template Resolution
How Facet Finds Templates
When you request a URL, Facet walks up the directory tree looking for templates.
Template Resolution Algorithm
When requesting /shop/products/65abc123... (a document):
1. templates/shop/products/65abc123.../view.html ❌ (document-specific, doesn't exist)
2. templates/shop/products/view.html ✅ FOUND!
3. templates/shop/products/index.html (optional fallback)
4. templates/shop/view.html (parent-level fallback)
5. templates/shop/index.html (parent directory fallback)
6. templates/view.html (global fallback)
7. templates/index.html (root fallback)
8. No template found → return JSON (API unchanged)
For a collection request (/shop/products):
1. templates/shop/products/list.html ✅ (recommended)
2. templates/shop/products/index.html (fallback)
3. templates/shop/list.html (parent fallback)
...up the tree...
7. No template → return JSON
Try It Yourself: Test the Fallback
- Rename the view template:
cd examples/product-catalog mv templates/shop/products/view.html templates/shop/products/view.html.backup - Visit a product detail page — Facet now falls back to the next available template
- Restore:
mv templates/shop/products/view.html.backup templates/shop/products/view.html
5. Level 4: MongoDB Query Parameters
RESTHeart Query Support
RESTHeart provides MongoDB query parameters that Facet templates can use.
| Parameter | Description | Example |
|---|---|---|
filter |
MongoDB query in JSON | ?filter={"category":"Electronics"} |
sort |
Sort specification | ?sort={"price":1} (ascending) |
keys |
Field projection | ?keys={"name":1,"price":1} |
page |
Page number | ?page=2 |
pagesize |
Items per page | ?pagesize=10 |
Sort links in the list template:
<nav>
<a href="?sort_by=name" class="secondary">Name</a>
<a href="?sort_by=price" class="secondary">Price</a>
<a href="?sort_by=category" class="secondary">Category</a>
</nav>
Try It Yourself
- Visit
http://localhost:8080/shop/products?filter={"category":"Audio"}— only audio products - Visit
http://localhost:8080/shop/products?filter={"price":{"$lt":100}}&sort={"price":1}— products under $100, sorted by price
6. Level 5: Pagination
Automatic Pagination
RESTHeart automatically paginates results. Facet provides context variables for building pagination UI:
{% if totalPages > 1 %}
<nav aria-label="Pagination">
{% if page > 1 %}
<a href="?page={{ page - 1 }}">← Previous</a>
{% endif %}
<span>Page {{ page }} of {{ totalPages }}</span>
{% if page < totalPages %}
<a href="?page={{ page + 1 }}">Next →</a>
{% endif %}
</nav>
{% endif %}
Pagination Context Variables
page— current page (1-indexed)pagesize— items per page (default: 100)totalPages— total number of pagestotalDocuments— total count of documents
Try It Yourself
http://localhost:8080/shop/products?pagesize=3— 3 items per page- Use Previous/Next links to navigate
http://localhost:8080/shop/products?page=2&pagesize=5— direct page access
7. Level 6: HTMX Partial Updates
HTMX for Dynamic Updates
HTMX enables partial page updates without full reloads or JavaScript.
Sort links with HTMX:
<a href="?sort_by=name"
hx-get="?sort_by=name"
hx-target="#product-list"
hx-swap="innerHTML">
Name
</a>
HTMX attributes:
hx-get— make GET request to this URLhx-target— update this element's contenthx-swap— how to swap content (innerHTML,outerHTML, etc.)
How HTMX Fragment Resolution Works
When HTMX makes a request:
- HTMX sends headers:
HX-Request: trueandHX-Target: product-list - Facet detects the HTMX request
- Template resolver looks for a fragment template:
templates/shop/products/_fragments/product-list.html(resource-specific)templates/_fragments/product-list.html(root fallback)
- Strict mode: if fragment not found → 500 error (surfaces issues early)
Explore the Fragment Template
Open examples/product-catalog/templates/shop/products/_fragments/product-list.html
This is the partial template returned for HTMX requests:
- No
<html>,<head>, or<body>tags — just the fragment - Wraps content in
<div id="product-list">(the target element) - Contains the same product loop as the full page
Try It Yourself
- Open browser DevTools → Network tab
- Click a sort link (Name, Price, Category)
- Notice: only partial HTML is returned — no full page reload
- Edit the fragment template, add
🎯before{{ doc.name }} - Click a sort link → HTMX partial shows emoji
- Full page refresh → emoji also appears (both code paths use same fragment)
8. Level 7: Authentication and Authorization
User Accounts in the Example
| Username | Password | Role | Permissions |
|---|---|---|---|
admin |
secret |
admin | Full CRUD access |
viewer |
viewer |
viewer | Read-only access |
How It Works
Authentication — user credentials in users.yml:
users:
- userid: admin
password: secret
roles: [admin]
- userid: viewer
password: viewer
roles: [viewer]
Authorization — permissions in restheart.yml:
/fileAclAuthorizer:
permissions:
- role: admin
predicate: path-prefix[path=/]
mongo:
writeFilter: null # Can read and write
- role: viewer
predicate: path-prefix[path=/]
mongo:
writeFilter: '{"_id": {"$exists": false}}' # Read-only
Role-Based UI in Templates
{% if roles and 'admin' in roles %}
<button>+ Add Product</button>
{% endif %}
Try It Yourself
- Visit
http://localhost:8080/login, log in asadmin/secret - Click "+ Add Product" — button appears (admin only)
- Log out, log in as
viewer/viewer - "+ Add Product" button is hidden
- API test:
# Admin can write curl -u admin:secret -X POST http://localhost:8080/shop/products \ -H "Content-Type: application/json" \ -d '{"name":"Test","price":99.99}' # Viewer gets an error on write curl -u viewer:viewer -X POST http://localhost:8080/shop/products \ -H "Content-Type: application/json" \ -d '{"name":"Test","price":99.99}'
9. Level 8: Static Assets
RESTHeart serves static files alongside templates. Configuration in restheart.yml:
/static:
enabled: true
what: /static/
where: /opt/restheart/static
This maps /static/* URLs to the /opt/restheart/static directory on disk.
Referenced in templates:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<img src="{{ doc.imageUrl }}" alt="{{ doc.name }}">
Favicon at Root (Important!)
Browsers automatically request /favicon.ico. If authentication is required, this causes an auth popup. Serve favicon without auth:
/static-resources:
- what: /static/favicon.ico
where: /favicon.ico
embedded: true
10. Level 9: CRUD with HTMX Fragments
The Architecture
GET /shop/products/{id}→templates/shop/products/view.html(full page)- Edit button → HTMX loads
templates/_fragments/product-form.html(form only)
This keeps URLs clean and REST-compliant while providing rich interactivity.
Edit Product Flow
Product detail page (view.html):
<button hx-get="{{ path }}"
hx-target="#product-form"
hx-swap="innerHTML">
Edit Product
</button>
<div id="product-form" style="display: none;"></div>
How fragment resolution works:
- HTMX sends
HX-Request: trueandHX-Target: product-form - Facet checks
templates/shop/products/{id}/_fragments/product-form.html(not found) - Falls back to
templates/_fragments/product-form.html✅
The form fragment submits via fetch() with PATCH:
const response = await fetch('{{ path }}', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data)
});
if (response.ok) window.location.reload();
Create Product Flow
From the list page, "+ Add Product" loads templates/_fragments/product-new.html via HTMX.
The fragment submits via POST and reloads on success.
Delete Product
Uses fetch() with DELETE — no fragment needed:
async function deleteProduct() {
if (!confirm('Are you sure?')) return;
const response = await fetch('{{ path }}', {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) window.location.href = '{{ path | parentPath }}';
}
Pattern Benefits
- Clean URLs:
/products/123for view,/products/123?mode=editfor edit-open state (shareable) - No routing conflicts: a document with
_id: "edit"is unambiguous - Progressive enhancement: add POST/redirect fallback for no-JS clients
- Component reuse: same
product-form.htmlfrom both list and detail pages
11. Level 10: JavaScript Plugins
Beyond Templates: Server-Side Logic Without Java
Sometimes you need custom aggregation, computed fields, or data from multiple collections.
RESTHeart supports server-side plugins written in JavaScript — no Java, no recompile.
The key advantage: hot-reload. Edit a .mjs file and the next request picks up the new code automatically — exactly like editing a Pebble template.
The Product Statistics Plugin
The product catalog ships with product-stats: a plugin that queries the shop.products collection and computes aggregated inventory statistics.
Plugin files:
| File | Purpose |
|---|---|
package.json |
Declares the plugin to RESTHeart |
product-stats.mjs |
The service logic |
templates/shop/stats/index.html |
HTML dashboard template |
Visit: http://localhost:8080/shop/stats
You see an HTML dashboard with stat cards (total products, in-stock count, inventory value, average price) and a category breakdown table.
How the Plugin Works
export const options = {
name: "productStatsService",
description: "Aggregated statistics for the product catalog",
uri: "/shop/stats",
secured: true,
matchPolicy: "EXACT"
};
const BsonDocument = Java.type("org.bson.BsonDocument");
export function handle(request, response) {
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.getNumber("price").doubleValue();
const stock = doc.getNumber("stock").intValue();
if (stock > 0) inStock++;
totalValue += price * stock;
});
response.setContent(JSON.stringify({ total, inStock, totalValue }));
response.setContentTypeAsJson();
}
Key concepts:
Java.type(...)— GraalVM polyglot: access Java classes from JavaScriptmclient— global MongoDB Java driver client (injected by RESTHeart)LOGGER— global logger (LOGGER.info("message")for debugging)
How Facet Renders Plugin Output
The plugin returns JSON. Facet intercepts any service response and renders HTML using the matching template. For /shop/stats:
- Template:
templates/shop/stats/index.html✅ - All top-level JSON keys become template variables:
{{ total }},{{ inStock }}, etc.
Same endpoint, different Accept header:
# HTML dashboard
curl -u admin:secret http://localhost:8080/shop/stats -H "Accept: text/html"
# Raw JSON
curl -u admin:secret http://localhost:8080/shop/stats -H "Accept: application/json"
Plugin Manifest
{
"name": "product-stats",
"version": "1.0.0",
"rh:services": ["product-stats.mjs"]
}
RESTHeart scans any directory under /opt/restheart/plugins/ that contains a package.json with rh:services.
Try It Yourself: Hot-Reload
- Open
plugins/product-stats/product-stats.mjs - Add a new field to the stats object:
pluginVersion: "tutorial-test" - Refresh
http://localhost:8080/shop/stats— no restart needed - Check raw JSON —
pluginVersionnow appears - Revert the change
No mvn package. No docker compose restart. Just save and refresh.
12. Production Considerations
Configuration Changes for Production
Development (hot reload enabled, short cache):
/html-response-interceptor:
enabled: true
response-caching: false
max-age: 5
Production (ETag caching, longer max-age):
/html-response-interceptor:
enabled: true
response-caching: true
max-age: 300 # 5 minutes
Security Checklist
- Change default credentials — Never use
admin/secretin production - CORS settings — Restrict
allow-originto your domain - MongoDB connection — Use environment variables for credentials
- HTTPS — Enable SSL/TLS in RESTHeart configuration
Performance
ETag Caching: Facet generates ETags from rendered HTML hash. Browsers send If-None-Match → 304 Not Modified for unchanged content.
MongoDB Indexes: Add indexes for frequently queried fields:
db.products.createIndex({ category: 1 })
db.products.createIndex({ price: 1 })
db.products.createIndex({ name: "text" })
Health check: http://localhost:8080/_ping
Summary: Key Concepts Learned
- Path-Based Templates — template location mirrors API URL structure
- Template Context — rich variables automatically provided (documents, pagination, etc.)
- Hierarchical Resolution — template fallback up the directory tree
- MongoDB Queries — filter, sort, pagination via URL parameters
- HTMX Fragments — partial updates without writing JavaScript
- Dual Interface — same endpoint serves HTML (browsers) and JSON (APIs)
- JavaScript Plugins — custom server-side logic with hot-reload, no Java required
Next Steps
- Developer's Guide — Complete Facet architecture and APIs
- Template Context Reference — All available template variables
- RESTHeart Documentation — MongoDB API features
- Pebble Templates — Template syntax reference
- HTMX Documentation — Advanced HTMX patterns
Start Your Own Project
cp -r examples/product-catalog my-project
cd my-project
# 1. Edit init-data.js with your data
# 2. Update templates with your fields
# 3. Modify restheart.yml for your paths
docker compose up