Learn
Build a projects site
A tutorial: from a page in plain HTML to editable text, structured data, detail pages and the API.
This tutorial builds the projects site of Fieldwork, an example design studio, top-down. It starts with a page in plain HTML and adds editable text, structured project data, detail pages and the API, one step at a time. The result is the example shown on the home page and in our demo application. The complete source is in projects.zip.
You need a project with a running local site. See Installation.
1. Write the page
A route is a folder in ext/uri/. The home page is the folder itself. Replace the starter page's ext/uri/index.tpl:
[#set page-title="Work"#]
[#set body-classes="bg-stone-50 text-stone-900"#]
[#html#]
index.tpl sets a few page variables and hands over to [#html#], the standard page shell. The shell renders the document head, the stylesheets and scripts, and the page body from main.tpl in the same folder. Write ext/uri/main.tpl as plain HTML:
<section class="mx-auto max-w-7xl px-8 pb-10 pt-16">
<h1 class="max-w-4xl text-6xl font-semibold leading-none tracking-tight">Places people use every day</h1>
<div class="mt-8 max-w-2xl text-xl leading-relaxed text-stone-600">
<p>Fieldwork designs wayfinding, services and digital tools for libraries, ports, schools and markets.</p>
</div>
</section>
The classes are Tailwind CSS, which the project builds by default. Any HTML and CSS work here: the page is yours. Run ./nimbly watch while you edit, so new classes end up in the stylesheet.
2. Add a header and footer
The shell also renders header.tpl and footer.tpl from the route's folder. The header will be shared by several pages, so it becomes a reusable template. A folder in ext/tpl/ with an index.tpl is callable by its name. Create ext/tpl/site-header/index.tpl:
<div class="mx-auto flex max-w-7xl flex-col gap-4 px-8 py-6 sm:flex-row sm:items-center sm:justify-between sm:py-8">
<a href="[#base-url#]/" class="text-xl font-semibold tracking-tight">Fieldwork</a>
<nav class="flex flex-wrap gap-x-6 gap-y-2 text-stone-600 sm:gap-8">
<a href="[#base-url#]/#work" class="text-stone-900">Work</a>
<a href="[#base-url#]/#contact">Contact</a>
</nav>
</div>
On phones the logo and the menu stack; from the sm breakpoint up they share one row. Then ext/tpl/site-footer/index.tpl:
<div id="contact" class="mx-auto max-w-7xl px-8 py-12 text-stone-500">Fieldwork is an example studio.</div>
Then call them from the route. ext/uri/header.tpl holds one line, [#site-header#], and ext/uri/footer.tpl holds [#site-footer#]. [#base-url#] keeps links working when the site runs in a subfolder.
3. Make the text editable
Editors should change the heading and the introduction without a developer. Mark both as editable in main.tpl:
<section class="mx-auto max-w-7xl px-8 pb-10 pt-16">
<h1 class="max-w-4xl text-6xl font-semibold leading-none tracking-tight"
data-nb-edit="[#cfield title#]" data-nb-edit-options='{"plain":true}'>
[#get-html [#cfield title#] plain#]
</h1>
<div class="mt-8 max-w-2xl text-xl leading-relaxed text-stone-600" data-nb-edit="[#cfield intro#]">
[#get-html [#cfield intro#]#]
</div>
</section>
[#cfield title#] names a content field that belongs to this page, and [#get-html#] prints it. data-nb-edit lets a signed-in editor change it on the page: open the site while signed in, choose Edit in the page actions and type. Nimbly stores the text per page. No data structure is needed.
4. Define the projects
The studio's projects repeat and have the same fields, so they become a resource. A resource is a folder in ext/data/ with a .meta file that describes its fields. Create ext/data/projects/.meta:
{
"name": {"singular": "Project", "plural": "Projects"},
"title_field": "title",
"fields": {
"title": {"name": "Title", "type": "text", "required": true},
"title_slug": {"name": "URL slug", "type": "slug", "source": "title", "admin_col": false},
"sector": {
"name": "Sector", "type": "select", "admin_filter": true,
"options": {
"culture": "Culture", "public": "Public", "retail": "Retail",
"education": "Education", "mobility": "Mobility"
}
},
"year": {"name": "Year", "type": "text"},
"published": {"name": "Published", "type": "boolean", "admin_filter": true},
"cover": {"name": "Cover", "type": "image"},
"description": {
"name": "Description", "type": "html",
"buttons": "bold,italic,anchor", "admin_col": false
}
},
"index": ["title_slug"],
"sort": {"field": "year", "flags": "numeric", "order": "desc"}
}
That one file gives you:
- an overview at
/nb-admin/projectswith columns, a search and filters on sector and publication, - forms to add and edit projects, with an image picker and a text editor,
- validation of the required title,
- the REST API at
/api/v1/projects, - permissions per role and a history of every change.
The title_slug field is derived from the title and indexed, so a project can be found by its address. Add four or five projects in the admin, with a cover image, and mark them published. Each record is saved as a JSON file in ext/data/projects/.
5. Show the projects on the page
A card presents one project. It is only used on this page, so it can live in the route's folder: a .tpl file next to index.tpl is a template for that route, not a new address. Create ext/uri/card-project.tpl:
<li id="project-[#item.uuid#]">
<div class="overflow-hidden rounded-2xl bg-stone-200" data-nb-edit-img="projects.[#item.uuid#].cover">
[#get-img-html [#item.cover#] sizes="sm-100,md-50,lg-33"#]
</div>
<p class="mt-5 text-sm uppercase tracking-widest text-stone-500">[#item.sector#] · [#item.year#]</p>
<h3 class="mt-2 text-2xl font-semibold"><a href="[#base-url#]/projects/[#item.title_slug#]">[#item.title#]</a></h3>
<div class="mt-3 leading-relaxed text-stone-600" data-nb-edit="projects.[#item.uuid#].description">
[#get-html item.description#]
</div>
</li>
Add the list to main.tpl, below the introduction:
<section id="work" class="mx-auto max-w-7xl px-8 py-12">
[#data projects filter=published:1 sort=year|numeric|desc#]
<ul class="grid gap-x-8 gap-y-14 md:grid-cols-2 lg:grid-cols-3">
[#repeat data.projects tpl=card-project#]
</ul>
</section>
[#data#] selects the published projects, newest first, and [#repeat#] renders the card once for each, with the record in item. [#get-img-html#] writes a responsive image: the server makes the sizes the layout asks for, in WebP. The description and the cover are editable on the page as well as in the admin form, because they point at the record's field.
The template contains no logic: it selects, repeats and prints. When a page needs a decision or a calculation, that goes in a library.
6. Give each project a page
A project's address is /projects/ followed by its slug. A folder name in parentheses is a dynamic segment. Create ext/uri/projects/(slug)/route.inc, which decides whether the address belongs to a published project:
<?php
$parts = router_match(__FILE__);
if ($parts === false) return;
// A slug is lowercase letters, digits and dashes; anything else is not a project
$slug = $parts[0];
if (!preg_match('/^[a-z0-9-]{1,120}$/', $slug)) return;
load_library('data');
load_library('util');
$records = data_read_index('projects', 'title_slug', md5_uuid($slug));
$record = $records ? reset($records) : null;
if (!$record || empty($record['published'])) return;
set_variable_dot('project', $record);
router_accept();
The route checks the input, looks the slug up in the index, and accepts the request only for a published project. Anything else falls through to a 404. Add index.tpl:
[#set page-title="[#project.title#]"#]
[#set body-classes="bg-stone-50 text-stone-900"#]
[#html#]
a header.tpl with [#site-header#], a footer.tpl with [#site-footer#], and main.tpl:
<article class="mx-auto max-w-4xl px-8 pb-24 pt-10">
<a href="[#base-url#]/#work" class="text-sm text-stone-500">← Work</a>
<p class="mt-10 text-sm uppercase tracking-widest text-stone-500">[#project.sector#] · [#project.year#]</p>
<h1 class="mt-3 text-5xl font-semibold leading-tight tracking-tight">[#project.title#]</h1>
<div class="mt-10 overflow-hidden rounded-2xl bg-stone-200">
[#get-img-html [#project.cover#] sizes="sm-100,md-100,lg-75"#]
</div>
<div class="mt-10 max-w-2xl text-xl leading-relaxed text-stone-600">
[#get-html project.description#]
</div>
</article>
Dynamic routes are registered, so the router knows to ask them. Run:
./nimbly routes:sync
The project titles on the home page now lead to their own pages.
7. Preview from the admin
Editors work in the admin and want to see the page they are editing. Add a button to each project's action panel. Create ext/tpl/btn-preview/index.tpl:
<a href="[#base-url#]/projects/[#record.title_slug#]" class="[#btn-class-secondary#] flex w-full items-center justify-center text-center">[#text Preview#]</a>
and list it in the resource's .meta, next to fields:
"record_actions": [
{"template": "btn-preview", "scope": "view,edit"}
]
8. Use the data anywhere
The same records answer over the REST API, for an app, a screen or another system. A caller signs in as a Nimbly user, gets a token and asks for records.
A user may request a token only when API access is on for their account. Switch on API access in the user's action panel in the admin, beside the edit form at /nb-admin/users/<uuid>; it needs a role that may edit users. Switching it off stops the user's token at once.
Then:
TOKEN=$(curl -s -X POST http://localhost/api/v1/auth/token \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com", "password": "your password"}' | jq -r .token)
curl "http://localhost/api/v1/projects?filter=published:1&sort=year|desc" \
-H "Authorization: Bearer $TOKEN"
What a caller may do follows its role, exactly as in the admin. For a real integration, give the caller its own user with a role that may only read projects. See Integrations.
9. Go further: a map with another library
The example also has a map of the projects at /map, built with Leaflet, a library the project does not include by default. Its .meta adds a location-picker field, so editors place each project on a map in the admin form. The route loads Leaflet and its own script only on that page, and reads the projects with [#data#], passed to the script as JSON. That pattern works for any library or framework. See uri/map/ in the download and Frontend stack.
What you built
| Building block | Files | Role |
|---|---|---|
| Routes | ext/uri/, ext/uri/projects/(slug)/ |
Addresses and page composition |
| Templates | site-header, site-footer, card-project, btn-preview |
Presentation, reused across pages |
| Resource | ext/data/projects/.meta |
Data, admin, API and permissions |
| Content | page fields through [#cfield#] |
Text editors change in place |
From here:
- Atomic composition explains how these blocks combine.
- Resources covers fields, indexes and history.
- Users and permissions sets up an editor role.