This guide 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'll:
- 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/- no need to recreate it!
- 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
# 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
# If you want a local image (for plugin changes)
# mvn package -DskipTests
# docker compose up --buildWait for services to start (few seconds).
You should see a styled product catalog with laptops, headphones, and other electronics.
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
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.
Request path = template path
When you visit /shop/products, Facet looks for a template at templates/shop/products/.
Open examples/product-catalog/templates/shop/products/list.html
Key sections to notice:
Lines 1-12: Standard HTML with Pico CSS
<!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:
- Pico CSS is loaded from CDN for easy setup
{{ database }}is a template variable from Facet's context
Lines 25-29: 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.
-
Edit the template - Change line 27 from:
<h3>{{ doc.name }}</h3>
to:
<h3>🛍️ {{ doc.name }}</h3>
-
Refresh your browser - See the emoji appear instantly (hot reload enabled)
-
Revert the change - Remove the emoji
Facet uses explicit action-aware resolution:
- Collection requests → looks for
list.htmlfirst, thenindex.html(optional fallback) - Document requests → looks for
view.htmlfirst, thenindex.html(optional fallback)
Recommended: Use explicit templates (list.html and view.html) for cleaner code without conditional logic. This example uses list.html for the collection view and view.html for the document view, keeping each template focused and simple.
Facet automatically provides rich context variables to every template.
Open examples/product-catalog/templates/shop/products/list.html and look at lines 20-23:
<header>
<h1>{{ database | capitalize }} - Products Catalog</h1>
<p>Showing {{ documents | length }} products</p>
</header>Variables used here:
database- Database name from URL path ("shop")documents- Array of product documents from MongoDB| capitalize- Pebble filter (built-in)| length- Pebble filter (built-in)
Open docs/TEMPLATE_CONTEXT_REFERENCE.md to see all variables.
Most commonly used:
| 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 |
Add a debug section to see all variables:
-
Open examples/product-catalog/templates/shop/products/list.html
-
Add this before the closing
</main>tag (around line 50):
<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>pagesize:</strong> {{ pagesize }}</li>
<li><strong>totalPages:</strong> {{ totalPages }}</li>
<li><strong>totalDocuments:</strong> {{ totalDocuments }}</li>
</ul>
</details>-
Refresh and expand the "Debug: Context Variables" section
-
Remove this debug section when done exploring
When you request a URL, Facet walks up the directory tree looking for templates.
Visit: http://localhost:8080/shop/products/{any-product-id}
Click on any product from the list to see its detail page.
Open examples/product-catalog/templates/shop/products/view.html
Key sections:
Lines 16-18: Single document access
{% if documents is not empty %}
{% set product = documents[0] %}
<h1>{{ product.name }}</h1>For document requests, documents contains a single item (the requested document).
Lines 45-47: Navigation back to list
<a href="{{ path | stripTrailingSlash | parentPath }}" role="button" class="secondary">
← Back to Products
</a>Uses custom Facet filters: stripTrailingSlash and parentPath
When requesting /shop/products/65abc123...:
Facet looks for templates in this order:
templates/shop/products/65abc123.../view.html❌ (document-specific, doesn't exist)templates/shop/products/view.html✅ FOUND!templates/shop/products/index.html(optional fallback if view.html missing)templates/shop/view.html(parent-level document template)templates/shop/index.html(parent directory fallback)templates/view.html(global document template)templates/index.html(root fallback)- No template found → return JSON (API unchanged)
This is hierarchical resolution - walks up the tree until it finds a template.
Test the fallback behavior:
-
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 - You'll now see
list.htmlused (fallback to index.html equivalent) -
Restore the template:
mv templates/shop/products/view.html.backup templates/shop/products/view.html
RESTHeart provides powerful MongoDB query parameters that Facet templates can use.
Look at the sort links in templates/shop/products/list.html lines 32-38:
<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>Visit these URLs to see sorting in action:
- http://localhost:8080/shop/products?sort_by=name
- http://localhost:8080/shop/products?sort_by=price
- http://localhost:8080/shop/products?sort_by=-price (descending)
RESTHeart supports these query parameters (all available as context variables):
| 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 |
Test filtering via URL:
Visit: http://localhost:8080/shop/products?filter={"category":"Audio"}
Only audio products (headphones, earbuds) should appear.
Test combination:
Visit: http://localhost:8080/shop/products?filter={"price":{"$lt":100}}&sort={"price":1}
Products under $100, sorted by price ascending.
RESTHeart automatically paginates results. Facet provides context variables for building pagination UI.
Look at templates/shop/products/list.html lines 53-68:
{% 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 %}page- Current page (1-indexed)pagesize- Items per page (default: 100)totalPages- Total number of pagestotalDocuments- Total count of documents
Test pagination:
- Change page size: http://localhost:8080/shop/products?pagesize=3
- Navigate pages: Use Previous/Next links
- Direct page access: http://localhost:8080/shop/products?page=2&pagesize=5
The example has ~10 products, so you'll see multiple pages when pagesize is small.
HTMX enables partial page updates without full reloads or writing JavaScript.
Look at templates/shop/products/list.html lines 34-37:
<a href="?sort_by=name"
hx-get="?sort_by=name"
hx-target="#product-list"
hx-swap="innerHTML">
Name
</a>HTMX attributes explained:
hx-get- Make GET request to this URLhx-target- Update this element's contenthx-swap- How to swap content (innerHTML, outerHTML, etc.)
When HTMX makes a request:
- HTMX sends headers:
HX-Request: trueandHX-Target: product-list - Facet detects HTMX request via
HtmxRequestDetector - Template resolver looks for 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 (design decision to surface errors early)
Open examples/product-catalog/templates/shop/products/_fragments/product-list.html
This is the partial template returned for HTMX requests.
Notice:
- No
<html>,<head>, or<body>tags (just the fragment) - Wraps content in
<div id="product-list">(the target element) - Contains same product loop as full page
- Open browser DevTools (Network tab)
- Click a sort link (Name, Price, Category)
- Notice in Network tab:
- Only partial HTML returned (not full page)
- Page doesn't flash/reload
- URL updates in address bar
- View the response - It's just the
#product-listdiv content
Test fragment template changes:
- Edit templates/shop/products/_fragments/product-list.html
- Add an emoji to line 8:
<h3>🎯 {{ doc.name }}</h3> - Click a sort link (HTMX update) → see emoji
- Full page refresh → emoji also appears
Both full page and HTMX partial now show the same updated HTML.
The product catalog demonstrates RESTHeart's separation of authentication (who you are) from authorization (what you can do).
The example includes two pre-configured users:
| Username | Password | Role | Permissions |
|---|---|---|---|
admin |
secret |
admin | Full CRUD access |
viewer |
viewer |
viewer | Read-only access |
User credentials and role assignments are in users.yml:
users:
- userid: admin
password: secret
roles:
- admin
- userid: viewer
password: viewer
roles:
- viewerThis file contains ONLY credentials and role assignments - no permissions.
Permissions (ACL) are in restheart.yml:
/fileAclAuthorizer:
enabled: true
permissions:
# Admin can do anything
- role: admin
predicate: path-prefix[path=/]
priority: 0
mongo:
readFilter: null
writeFilter: null
# Viewer can only read
- role: viewer
predicate: path-prefix[path=/]
priority: 1
mongo:
readFilter: null
writeFilter: '{"_id": {"$exists": false}}'This defines what each role can do (read vs write operations).
When you log in:
- Browser sends credentials to
/token/cookieendpoint fileRealmAuthenticatorvalidates credentials from users.ymljwtTokenManagerissues a JWT token with username and rolesauthCookieSetterstores JWT inrh_authcookie- Templates receive
usernameandrolesvariables fileAclAuthorizerenforces role-based permissions
Test the authentication:
- Visit: http://localhost:8080/login
- Log in as
admin/secret - Click "+ Add Product" - button should appear (admin-only feature)
- Log out, log in as
viewer/viewer - "+ Add Product" button is hidden (viewers can't create)
- Try to access the API:
# 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 can only read curl -u viewer:viewer http://localhost:8080/shop/products
Test role-based UI:
Open templates/shop/products/list.html and find line ~18:
{% if roles and 'admin' in roles %}
<button>+ Add Product</button>
{% endif %}Templates use the roles variable to show/hide features based on user permissions.
See the product-catalog README Authentication Setup section for detailed instructions on:
- Adding new users with different roles
- Configuring custom permissions
- Alternative authentication methods (MongoDB, LDAP, OAuth2)
Facet integrates with RESTHeart's static file serving.
Open examples/product-catalog/restheart.yml lines 32-38:
/static:
enabled: true
what: /static/
where: /opt/restheart/static
embedded-resources-prefix: null
index-file: null
etag-policy: IF_MATCH_POLICYThis maps /static/* URLs to the /opt/restheart/static directory.
Check examples/product-catalog/static/:
static/
└── images/
└── placeholder-*.svg # Product images
Note: Pico CSS is loaded from CDN (https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css) for easier setup without local dependencies.
Look at templates/layout.html line 9:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">And lines 30-35 in the product template for images:
<img src="{{ doc.imageUrl }}" alt="{{ doc.name }}">Test static file serving:
- Visit: http://localhost:8080/static/images/ (see images if any are added)
- Visit: http://localhost:8080/static/images/placeholder-laptop.svg (see image)
Add a custom style:
- Edit examples/product-catalog/static/custom.css
- Add at the end:
article h3 { color: #ff6b6b; font-weight: bold; }
- Refresh the product list → product names now red
Learn how the product catalog implements full CRUD functionality using HTMX fragments.
Facet uses path-based template resolution:
GET /shop/products/{id}→ templates/shop/products/view.html- Forms load as HTMX fragments → templates/_fragments/product-form.html
This keeps URLs clean and REST-compliant while providing rich interactivity.
- View Product: Click product card → Full page with URL
/products/{id} - Edit Product: Click "Edit" button → Form loads inline via HTMX (URL stays
/products/{id}) - Create Product: Click "+ Add Product" → Form loads as fragment
- Delete Product: Click "Delete" → Confirmation dialog + API call
Open templates/shop/products/view.html (lines 40-55):
<footer style="margin-top: 2rem;">
<div class="grid">
<a href="{{ path | parentPath }}" role="button" class="secondary">
← Back to Products
</a>
<button hx-get="{{ path }}"
hx-target="#product-form"
hx-swap="innerHTML">
Edit Product
</button>
<button class="secondary" onclick="deleteProduct()">
Delete Product
</button>
</div>
</footer>
<div id="product-form" style="display: none;"></div>Key HTMX attributes:
hx-get="{{ path }}"- Requests current URL (e.g.,/shop/products/123)hx-target="#product-form"- Tells Facet to load fragment for targetproduct-formhx-swap="innerHTML"- Replaces content inside the div
When HTMX makes the request:
-
HTMX sends headers:
HX-Request: true- Identifies as HTMX requestHX-Target: product-form- Specifies which fragment to load
-
Facet resolves fragment template:
- Checks:
templates/shop/products/{documentId}/_fragments/product-form.html(document-specific) - Fallback:
templates/_fragments/product-form.html✅ (root level)
- Checks:
-
Returns just the form HTML (not full page)
Open templates/_fragments/product-form.html:
Lines 1-50: Pico CSS form:
<article>
<header>
<h2>Edit Product</h2>
<p><a href="{{ path | parentPath }}" class="secondary">← Back to Products</a></p>
</header>
<form id="productEditForm">
<label>
Name
<input type="text" name="name" value="{{ product.data.name }}" required>
</label>
<label>
Description
<textarea name="description" rows="3">{{ product.data.description }}</textarea>
</label>
<div class="grid">
<label>
Price ($)
<input type="number" name="price" value="{{ product.data.price }}" step="0.01" required>
</label>
<label>
Stock
<input type="number" name="stock" value="{{ product.data.stock }}" required>
</label>
</div>
<div class="grid">
<button type="submit">Save Changes</button>
<button type="button" class="secondary" onclick="cancelEdit()">Cancel</button>
</div>
</form>
</article>Lines 52-95: JavaScript form submission:
document.getElementById('productEditForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(e.target);
// Build JSON data
const data = {
name: formData.get('name'),
description: formData.get('description'),
price: parseFloat(formData.get('price')),
stock: parseInt(formData.get('stock')),
// ... other fields
};
// PATCH request to update document
const response = await fetch('{{ path }}', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'include',
body: JSON.stringify(data)
});
if (response.ok) {
// Success: reload page (removes query parameters)
const url = new URL(globalThis.location.href);
url.search = '';
globalThis.location.href = url.toString();
} else {
// Error handling
const errorText = await response.text();
alert('Error: ' + errorText);
}
});The create flow works similarly. Open templates/shop/products/list.html (lines 18-26):
<button hx-get="{{ path }}"
hx-target="#product-new"
hx-swap="innerHTML">
+ Add Product
</button>
<div id="product-new" style="display: none;"></div>Fragment: templates/_fragments/product-new.html
Uses POST method instead of PATCH:
const response = await fetch('{{ path }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'include',
body: JSON.stringify(data)
});
if (response.ok) {
window.location.reload(); // Reload to show new product
}Delete uses JavaScript fetch() with DELETE method (no fragment needed):
async function deleteProduct() {
if (!confirm('Are you sure you want to delete this product?')) {
return;
}
const response = await fetch('{{ path }}', {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
// Navigate back to product list
window.location.href = '{{ path | parentPath }}';
}
}Watch the Network Tab:
- Open browser DevTools → Network tab
- Click "Edit Product" on a product page
- Notice the HTMX request:
- Request headers include
HX-Request: trueandHX-Target: product-form - Response is just the form HTML (not a full page)
- No page flash/reload
- Request headers include
- Submit the form
- Notice the PATCH request:
- Method: PATCH
- Content-Type: application/json
- Body: JSON representation of form data
Test the URL pattern:
- View a product at
/shop/products/{id} - Click "Edit" - URL stays
/shop/products/{id}, form loads inline - Compare to traditional approach:
/shop/products/{id}/editwould be a new page
Test deep linking:
- From product list, click an "Edit" button
- URL becomes
/shop/products/{id}?mode=edit - Share this URL with someone (or bookmark it)
- When visited, page auto-opens edit form via JavaScript detection
- templates/shop/products/view.html - Product detail page with HTMX buttons
- templates/shop/products/list.html - Product list with "Add Product" button
- templates/_fragments/product-form.html - Edit form (HTMX fragment)
- templates/_fragments/product-new.html - Create form (HTMX fragment)
This pattern works for any resource:
- Create view template:
templates/{resource}/view.htmlfor the detail page - Create form fragment:
templates/_fragments/{resource}-form.htmlfor editing - Add HTMX trigger: Button with
hx-get+hx-targetto load fragment - Submit via fetch(): Use appropriate HTTP method (POST/PATCH/DELETE)
- Keep URLs clean: Resource URLs stay as
/resource/{id}, not/resource/{id}/edit
Clean URLs:
/products/123- View product/products/123?mode=edit- View product with edit form open (shareable!)- No need for
/products/123/editroute
No Routing Conflicts:
- What if you have a document with
_id: "edit"? - With action suffixes:
/products/editis ambiguous (document or action?) - With fragments:
/products/editis always the document, edit form loads via HTMX
Progressive Enhancement:
- JavaScript enabled: Smooth inline forms via HTMX
- JavaScript disabled: Can add traditional POST/redirect fallback
Component Reusability:
- Same
product-form.htmlfragment used from both list and detail pages - Consistent UI without code duplication
So far every feature has used MongoDB directly through RESTHeart's built-in CRUD endpoints. But sometimes you need custom aggregation, computed fields, or data from multiple collections. For that, RESTHeart supports server-side plugins written in JavaScript — no Java required.
The key advantage over Java plugins: hot-reload. Edit a .mjs file, make the next request, and the new code runs immediately. No mvn package, no Docker restart — exactly like editing a Pebble template.
The product catalog ships with a working JavaScript plugin: product-stats. It queries the shop.products collection and computes aggregated inventory statistics.
Plugin files:
| File | Purpose |
|---|---|
| plugins/product-stats/package.json | Declares the plugin to RESTHeart |
| plugins/product-stats/product-stats.mjs | The service logic |
| templates/shop/stats/index.html | HTML dashboard template |
Visit the stats page: http://localhost:8080/shop/stats
You should see an HTML dashboard with stat cards (total products, in-stock count, total inventory value, average price) and a category breakdown table.
Open plugins/product-stats/product-stats.mjs:
The plugin declaration:
export const options = {
name: "productStatsService",
description: "Aggregated statistics for the product catalog",
uri: "/shop/stats",
secured: true,
matchPolicy: "EXACT"
};This registers the service at the /shop/stats URL.
The handle function:
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("org.bson.BsonDocument")— GraalVM polyglot interop: access Java classes from JavaScriptmclient— global MongoDB Java driver client (injected by RESTHeart)LOGGER— global logger (useLOGGER.info("message")for debugging)- BSON accessor methods:
.getString("key").getValue(),.getNumber("key").doubleValue(),.containsKey("key")
The plugin returns JSON. Facet's rendering pipeline intercepts any service response — not just MongoDB — and renders HTML using the matching template.
For /shop/stats:
1. templates/shop/stats/index.html ✅ FOUND
The template receives every top-level key from the JSON response as a template variable. So { "total": 10, "inStock": 8 } becomes {{ total }} and {{ inStock }} in the template.
Same endpoint, different Accept header:
# HTML dashboard (browser)
curl -u admin:secret http://localhost:8080/shop/stats -H "Accept: text/html"
# Raw JSON (API client)
curl -u admin:secret http://localhost:8080/shop/stats -H "Accept: application/json"The JSON and HTML responses show the same numbers — the template just decorates the data.
-
Find where the stats object is built and add a new field:
const stats = { total, inStock, // ... existing fields pluginVersion: "tutorial-test" // ← add this line };
-
Refresh http://localhost:8080/shop/stats (no restart needed)
-
Check the raw JSON at http://localhost:8080/shop/stats with
Accept: application/json—pluginVersionnow appears -
Revert the change
No mvn package. No docker compose restart. Just save and refresh.
Open plugins/product-stats/package.json:
{
"name": "product-stats",
"version": "1.0.0",
"rh:services": ["product-stats.mjs"]
}rh:services— array of.mjsfiles to load as RESTHeart servicesrh:interceptors— (not used here) array of.mjsinterceptor files
RESTHeart scans any directory under /opt/restheart/plugins/ that contains a package.json with these keys.
Open docker-compose.yml and find the facet service volumes:
volumes:
- ./templates:/opt/restheart/templates:ro
- ./plugins/product-stats:/opt/restheart/plugins/product-stats:roEach plugin folder gets its own volume mount. Read-only (:ro) is fine because hot-reload reads the file on each request via the host bind mount.
- plugins/product-stats/product-stats.mjs — plugin logic
- plugins/product-stats/package.json — plugin manifest
- templates/shop/stats/index.html — HTML dashboard
For a full guide on writing your own JavaScript plugins, see DEVELOPERS_GUIDE.md — JavaScript Plugins.
The example is configured for development. Here's what to adjust for production.
Compare development vs production settings:
Development (examples/product-catalog/restheart.yml):
/html-response-interceptor:
enabled: true
response-caching: false # Hot reload
max-age: 5 # Short cacheProduction:
/html-response-interceptor:
enabled: true
response-caching: true # Enable ETag caching
max-age: 300 # 5 minutesFor production, review:
- Authentication - See examples/product-catalog/users.yml for user setup
- CORS settings - Restrict
allow-originto your domain - MongoDB connection - Use environment variables for credentials
- HTTPS - Enable SSL/TLS in RESTHeart configuration
ETag Caching:
- Facet generates ETags from rendered HTML's hash
- Browsers send
If-None-Matchheader - 304 Not Modified responses 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" })Check RESTHeart logs:
docker-compose logs -f restheartHealth check endpoint: http://localhost:8080/_ping
Through this walkthrough, you explored:
- 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 JavaScript
- Static Assets - CSS, JS, images served alongside templates
- Dual Interface - Same endpoint serves HTML (browsers) and JSON (APIs)
- JavaScript Plugins - Custom server-side logic with hot-reload, no recompile
Try these modifications to deepen your understanding:
- Add a new field - Edit init-data.js, add
descriptionfield, show it in templates - Create a detail fragment - Make the view page support HTMX updates
- Add filtering UI - Create category filter buttons using HTMX
- Build a search bar - Use MongoDB text search with
$textoperator - Customize styling - Modify static/custom.css
- Extend the JS plugin - Add a new computed field to product-stats.mjs and display it in the stats template (no restart needed)
- Developer's Guide - Complete Facet architecture and APIs
- Template Context Reference - All available context variables
- RESTHeart Documentation - MongoDB API features
- Pebble Templates - Template syntax reference
- HTMX Documentation - Advanced HTMX patterns
Start a new project using the example as a template:
# Copy the example
cp -r examples/product-catalog my-project
cd my-project
# Customize for your data model
# 1. Edit init-data.js with your data
# 2. Update templates with your fields
# 3. Modify restheart.yml for your paths
# 4. Start building!Happy building with Facet! 🚀