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:

  1. Run the working example in 2 minutes
  2. Explore the code to understand how it works
  3. 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

  1. Get Started in 2 Minutes
  2. Level 1: Understanding Path-Based Templates
  3. Level 2: Template Context Variables
  4. Level 3: Hierarchical Template Resolution
  5. Level 4: MongoDB Query Parameters
  6. Level 5: Pagination
  7. Level 6: HTMX Partial Updates
  8. Level 7: Authentication and Authorization
  9. Level 8: Static Assets
  10. Level 9: CRUD with HTMX Fragments
  11. Level 10: JavaScript Plugins
  12. 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:

  1. MongoDB — database with sample products (loaded from init-data.js)
  2. RESTHeart — REST API server with Facet plugin
  3. 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

  1. Edit the template — change <h3>{{ doc.name }}</h3> to <h3>🛍️ {{ doc.name }}</h3>
  2. Refresh your browser — see the emoji appear instantly (hot reload)
  3. Revert the change

Template Naming Convention

Facet uses explicit action-aware resolution:

  • Collection requests → looks for list.html first, then index.html (fallback)
  • Document requests → looks for view.html first, then index.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
  • | capitalize and | 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

  1. Rename the view template:
    cd examples/product-catalog
    mv templates/shop/products/view.html templates/shop/products/view.html.backup
  2. Visit a product detail page — Facet now falls back to the next available template
  3. 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 pages
  • totalDocuments — total count of documents

Try It Yourself

  1. http://localhost:8080/shop/products?pagesize=3 — 3 items per page
  2. Use Previous/Next links to navigate
  3. 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 URL
  • hx-target — update this element's content
  • hx-swap — how to swap content (innerHTML, outerHTML, etc.)

How HTMX Fragment Resolution Works

When HTMX makes a request:

  1. HTMX sends headers: HX-Request: true and HX-Target: product-list
  2. Facet detects the HTMX request
  3. Template resolver looks for a fragment template:
    • templates/shop/products/_fragments/product-list.html (resource-specific)
    • templates/_fragments/product-list.html (root fallback)
  4. 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

  1. Open browser DevTools → Network tab
  2. Click a sort link (Name, Price, Category)
  3. Notice: only partial HTML is returned — no full page reload
  4. Edit the fragment template, add 🎯 before {{ doc.name }}
  5. Click a sort link → HTMX partial shows emoji
  6. 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

  1. Visit http://localhost:8080/login, log in as admin / secret
  2. Click "+ Add Product" — button appears (admin only)
  3. Log out, log in as viewer / viewer
  4. "+ Add Product" button is hidden
  5. 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:

  1. HTMX sends HX-Request: true and HX-Target: product-form
  2. Facet checks templates/shop/products/{id}/_fragments/product-form.html (not found)
  3. 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/123 for view, /products/123?mode=edit for 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.html from 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 JavaScript
  • mclient — 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

  1. Open plugins/product-stats/product-stats.mjs
  2. Add a new field to the stats object: pluginVersion: "tutorial-test"
  3. Refresh http://localhost:8080/shop/stats — no restart needed
  4. Check raw JSON — pluginVersion now appears
  5. 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

  1. Change default credentials — Never use admin / secret in production
  2. CORS settings — Restrict allow-origin to your domain
  3. MongoDB connection — Use environment variables for credentials
  4. 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

  1. Path-Based Templates — template location mirrors API URL structure
  2. Template Context — rich variables automatically provided (documents, pagination, etc.)
  3. Hierarchical Resolution — template fallback up the directory tree
  4. MongoDB Queries — filter, sort, pagination via URL parameters
  5. HTMX Fragments — partial updates without writing JavaScript
  6. Dual Interface — same endpoint serves HTML (browsers) and JSON (APIs)
  7. JavaScript Plugins — custom server-side logic with hot-reload, no Java required

Next Steps

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