Add a Web UI
In this tutorial you will extend the task tracker module with API routes, HTML templates, and Unpoly v3 interactions to create a full-stack web interface. By the end you will have a working application that lists tasks, creates new ones, and manages their lifecycle -- all with SPA-like navigation and partial page updates, without writing any JavaScript.
Prerequisites: You should have completed Build a Task Tracker and be familiar with File-Based Routing, Templates & JQT, and API Routes.
What you'll learn:
- Adding a query to an existing module
- Creating collection and record API routes
- Building a root layout with Unpoly v3
- Writing list and detail page templates with data loaders
- Using
up-submit,up-target, andup-followfor instant interactions
Step 1: Project Structure
After this tutorial your project will look like this:
my-project/
grove.toml
modules/
task_tracker/
module.grove
task_tracker_test.grove
apps/
main/
layout.html
tasks/
page.html
page.jq
[id]/
page.html
page.jq
api/
task_tracker/
route.grove
[id]/
route.grove
The modules/ directory already contains the task tracker module you built
previously. In this tutorial you will add the apps/ tree that exposes it
through a web interface.
Step 2: Add a Query
Before building the UI you need a way to list tasks. Open
modules/task_tracker/module.grove and add a list_tasks query after the
existing action declarations:
query list_tasks {
args {
status: String?
limit: Int = 50
offset: Int = 0
}
sql {
"SELECT *
FROM task_tracker
ORDER BY created_at DESC
LIMIT @limit
OFFSET @offset"
}
}
The query accepts optional status, limit, and offset arguments. Bind
parameters use the @name syntax and correspond to declared argument names.
Routes and data loaders will reference this query as
task_tracker.list_tasks.
Step 3: API Routes
API routes give the UI (and any other client) a JSON interface to your module. You need two route files: one for the collection and one for individual records.
Collection route
Create apps/main/api/task_tracker/route.grove:
route {
GET {
data records {
query task_tracker.list_tasks {
limit: input.query.limit,
offset: input.query.offset
}
}
}
POST {
data result {
invoke task_tracker.create_task {
title: input.body.title,
description: input.body.description,
priority: input.body.priority,
assignee: input.body.assignee
}
}
}
}
GET /api/task_tracker returns the task list. POST /api/task_tracker
creates a new task by invoking the create_task action you defined in the
language guide.
Record route
Create apps/main/api/task_tracker/[id]/route.grove:
route {
GET {
data result {
fetch task_tracker { id: input.params.id }
}
}
POST start_task {
data result {
invoke task_tracker.start_task {
id: input.params.id,
assignee: input.body.assignee
}
}
}
POST complete_task {
data result {
invoke task_tracker.complete_task { id: input.params.id }
}
}
POST delete_task {
data result {
invoke task_tracker.delete_task { id: input.params.id }
}
}
}
GET /api/task_tracker/:id fetches a single task. The three named POST
handlers -- start_task, complete_task, and delete_task -- map to the
corresponding actions in the module. Named handlers let you expose multiple
operations under the same HTTP method on a single route.
Step 4: Root Layout
The root layout wraps every page in the app. It loads Unpoly v3 from a CDN,
sets up a sidebar with up-nav, and marks the main content area with
up-main.
Create apps/main/layout.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ "{{ .page.title }}" }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/unpoly@3/unpoly.min.css">
<script src="https://cdn.jsdelivr.net/npm/unpoly@3/unpoly.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
display: flex;
min-height: 100vh;
color: #1a1a1a;
background: #f5f5f5;
}
nav {
width: 220px;
background: #fff;
border-right: 1px solid #e0e0e0;
padding: 1.5rem 1rem;
flex-shrink: 0;
}
nav a {
display: block;
padding: 0.5rem 0.75rem;
border-radius: 6px;
color: #333;
text-decoration: none;
margin-bottom: 0.25rem;
}
nav a:hover { background: #f0f0f0; }
nav a.up-current { background: #e8f0fe; color: #1a56db; font-weight: 600; }
main {
flex: 1;
padding: 2rem 3rem;
max-width: 960px;
}
h1 { margin-top: 0; }
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-open { background: #dbeafe; color: #1e40af; }
.badge-in_progress { background: #fef3c7; color: #92400e; }
.badge-blocked { background: #fee2e2; color: #991b1b; }
.badge-done { background: #d1fae5; color: #065f46; }
.badge-cancelled { background: #e5e7eb; color: #374151; }
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #e5e7eb; }
th { font-weight: 600; font-size: 0.85rem; color: #6b7280; text-transform: uppercase; }
input, select, textarea {
padding: 0.4rem 0.6rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.9rem;
}
button {
padding: 0.4rem 1rem;
border: none;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
background: #1a56db;
color: #fff;
}
button:hover { background: #1e40af; }
button.danger { background: #dc2626; }
button.danger:hover { background: #b91c1c; }
button.secondary { background: #6b7280; }
button.secondary:hover { background: #4b5563; }
.form-row { display: flex; gap: 0.5rem; align-items: end; margin-bottom: 1rem; }
.form-row label { display: flex; flex-direction: column; font-size: 0.85rem; gap: 0.25rem; }
.detail-grid { display: grid; grid-template-columns: 10rem 1fr; gap: 0.5rem 1rem; margin: 1rem 0; }
.detail-grid dt { font-weight: 600; color: #6b7280; }
.actions { display: flex; gap: 0.5rem; margin-top: 1.5rem; }
</style>
</head>
<body>
<nav up-nav>
<h3>Task Tracker</h3>
<a href="/tasks" up-follow>All Tasks</a>
</nav>
<main up-main>
{{ "{{ .children | trusted }}" }}
</main>
</body>
</html>
What Unpoly gives you
up-navon the<nav>element tells Unpoly to add the.up-currentclass to whichever link matches the current URL. This highlights the active sidebar item automatically.up-mainon the<main>element marks it as the default swap target. When a link withup-followor a form withup-submitfires, Unpoly replaces the content insideup-mainrather than performing a full page navigation. The sidebar, styles, and scripts stay in place.up-followon links turns them into partial navigation requests. Unpoly fetches the new page, extracts theup-maincontent, and swaps it in -- giving you SPA-like page transitions with zero client-side routing.
Step 5: Task List Page
The task list page shows all tasks in a table and provides a form to create new ones.
Data loader
Create apps/main/tasks/page.jq:
{
"records": .data.records
}
This data loader passes through the records returned by the list_tasks
query. The template will access them as .data.records.
Template
Create apps/main/tasks/page.html:
---
title: All Tasks
---
<h1>Tasks</h1>
<section id="new-task">
<h2>New Task</h2>
<form action="/api/task_tracker" method="POST"
up-submit up-target="#task-list">
<div class="form-row">
<label>
Title
<input type="text" name="title" required>
</label>
<label>
Priority
<select name="priority">
<option value="medium" selected>Medium</option>
<option value="low">Low</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</label>
<label>
Assignee
<input type="text" name="assignee" placeholder="Optional">
</label>
<button type="submit">Create</button>
</div>
<div class="form-row">
<label style="flex:1">
Description
<textarea name="description" rows="2" style="width:100%"></textarea>
</label>
</div>
</form>
</section>
<section id="task-list">
<table>
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Priority</th>
<th>Assignee</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{ "{{ .data.records[] | \"<tr><td><a href=\\\"/tasks/\" + .id + \"\\\" up-follow>\" + .title + \"</a></td><td><span class=\\\"badge badge-\" + .status + \"\\\">\" + .status + \"</span></td><td>\" + .priority + \"</td><td>\" + (.assignee // \"-\") + \"</td><td>\" + .created_at + \"</td></tr>\" }}" }}
</tbody>
</table>
</section>
How it works
- The
<form>posts to/api/task_tracker-- the collection API route you created in Step 3.up-submittells Unpoly to submit the form via AJAX instead of a full page navigation. up-target="#task-list"tells Unpoly to reload only the#task-listsection after the form submits. Unpoly re-fetches the current page, extracts the#task-listfragment from the response, and swaps it in. The new task appears in the table without a full page reload.- Each task title is a link with
up-follow. Clicking it navigates to the detail page by swapping only theup-maincontent area. The sidebar stays in place. - The JQT iteration expression iterates over every record
and produces a table row for each one. The
//operator provides a fallback value whenassigneeis null.
Step 6: Task Detail Page
The detail page shows a single task and provides action buttons to advance it through its lifecycle.
Data loader
Create apps/main/tasks/[id]/page.jq:
{
"task": .data.result
}
Template
Create apps/main/tasks/[id]/page.html:
---
title: Task Detail
---
<a href="/tasks" up-follow>← Back to all tasks</a>
<h1>{{ "{{ .data.task.title }}" }}</h1>
<dl class="detail-grid">
<dt>Status</dt>
<dd><span class="badge badge-{{ "{{ .data.task.status }}" }}">{{ "{{ .data.task.status }}" }}</span></dd>
<dt>Priority</dt>
<dd>{{ "{{ .data.task.priority }}" }}</dd>
<dt>Assignee</dt>
<dd>{{ "{{ if .data.task.assignee then .data.task.assignee else \"Unassigned\" end }}" }}</dd>
<dt>Description</dt>
<dd>{{ "{{ if .data.task.description then .data.task.description else \"No description\" end }}" }}</dd>
<dt>Created</dt>
<dd>{{ "{{ .data.task.created_at }}" }}</dd>
<dt>Completed</dt>
<dd>{{ "{{ if .data.task.completed_at then .data.task.completed_at else \"-\" end }}" }}</dd>
</dl>
<div class="actions">
{{ "{{ if .data.task.status == \"open\" then \"<form action=\\\"/api/task_tracker/\" + .data.task.id + \"?handler=start_task\\\" method=\\\"POST\\\" up-submit up-target=\\\"main\\\"><label>Assignee <input type=\\\"text\\\" name=\\\"assignee\\\" required></label> <button type=\\\"submit\\\">Start Task</button></form>\" else \"\" end }}" }}
{{ "{{ if .data.task.status == \"in_progress\" then \"<form action=\\\"/api/task_tracker/\" + .data.task.id + \"?handler=complete_task\\\" method=\\\"POST\\\" up-submit up-target=\\\"main\\\"><button type=\\\"submit\\\">Complete Task</button></form>\" else \"\" end }}" }}
<form action="/api/task_tracker/{{ "{{ .data.task.id }}" }}?handler=delete_task" method="POST"
up-submit up-target="main"
up-confirm="Are you sure you want to delete this task?">
<button type="submit" class="danger">Delete</button>
</form>
</div>
How it works
- The back link uses
up-followso clicking it swaps only theup-maincontent, preserving the sidebar highlight transition. - The detail grid shows all task fields. JQT conditionals
(
if ... then ... else ... end) handle null values gracefully. - Action buttons are plain HTML forms that post to named handlers on the
record API route. The
?handler=start_taskquery parameter tells the route which named POST handler to invoke. - Each form uses
up-submitto send the request via AJAX andup-target="main"to replace the page content with the refreshed detail view. After starting a task, for example, the status badge updates to "in_progress" and the "Start Task" button disappears, replaced by "Complete Task". - The delete form adds
up-confirmto prompt the user before submitting.
Step 7: Running It
Start the development server:
manzano dev
Open your browser to main.localhost:3000. The sidebar shows "All Tasks" and
the main area is the task list.
Walkthrough
-
Create a task. Fill in the title, pick a priority, and click "Create". The task appears in the table without a page reload.
-
View the detail page. Click the task title. Unpoly swaps the main content to show the detail view while the sidebar stays in place.
-
Start the task. Enter an assignee name and click "Start Task". The status badge changes from "open" to "in_progress" and the action buttons update.
-
Complete the task. Click "Complete Task". The status moves to "done" and the
completed_attimestamp appears. -
Navigate back. Click the back link. The task list shows the updated status without a full page reload.
Every interaction is a standard HTML form submission -- Unpoly intercepts each one and upgrades it to a partial page update. If JavaScript fails to load, the app still works as a traditional server-rendered application with full page navigations.
Unpoly Patterns Reference
Here is a summary of the Unpoly attributes used in this tutorial:
| Attribute | Purpose |
|---|---|
up-main | Marks the default content swap target. Unpoly replaces this element's contents during navigation. |
up-nav | Marks a navigation container. Unpoly adds .up-current to links whose href matches the current URL. |
up-follow | Turns a link into a partial navigation request. Unpoly fetches the target page and swaps only the up-main content. |
up-submit | Submits a form via AJAX instead of a full page navigation. Combined with up-target to control which part of the page updates. |
up-target | Specifies a CSS selector identifying which element to replace after a navigation or form submission. |
up-confirm | Shows a confirmation dialog before submitting a form. The request is cancelled if the user declines. |
These six attributes cover the most common patterns for building interactive
server-rendered applications. See the
Unpoly documentation for additional attributes like
up-poll, up-transition, and up-layer.
See Also
- Build a Task Tracker -- The module this tutorial extends
- File-Based Routing -- How directories map to URLs
- Templates & JQT -- Template syntax reference
- Data Loaders -- The
page.jqconvention - API Routes -- Route file conventions
- Routes -- Full route block syntax
- Queries -- Query declaration reference