Building apps on Civility
Guides for building a Civility app — an offline-first, installable PWA whose data lives on the user’s devices and, when they choose, syncs through a Civility server (yours, theirs, or a hosted one).
A Civility app is a static bundle — HTML, an ES-module entry point, a service worker, and CSS — that civ build emits from a www/ source tree. No server-side rendering, no backend to run. Sync, encryption, and AI are packages you opt into per app.
Protocol-level design lives in the specs under docs/spec/; per-symbol API docs are generated at https://jsr.io/@civility/<package>/doc.
Quickstart #
1. Prerequisites
# Deno 2.x
curl -fsSL https://deno.land/install.sh | sh
# The Civility CLI (installs the `civ` binary)
deno install jsr:@civility/cli -Afg --name=civ --unstable-kv
civ icons (optional, for generating PWA icon sizes) shells out to ImageMagick — install it if you want icon generation, otherwise skip that step.
2. Scaffold and run
civ init my-app # or: civ init my-app --url https://my-app.example
cd my-app
deno task init # civ icons — generate icon sizes (needs ImageMagick; optional)
deno task dev # civ start — dev server at http://localhost:8000, live rebuild
civ init copies the PWA template into my-app/ — a civility.json build config, a deno.json, and a www/ tree with a working router, service worker, store, and example routes. deno task dev serves it and rebuilds on every save (--port 3000 to change the port). That’s already an installable, offline-first PWA running on local state — no server involved.
3. Build and deploy
deno task build # civ build → static bundle in civility.json "outdir" (e.g. ./www/dist)
civ build produces a version-namespaced JS bundle, the service worker, the web manifest, and generated icons. There’s no server component, so deploying is a drag-and-drop:
- Netlify Drop or Cloudflare Pages Direct Upload — drag the output folder in and you have a live HTTPS PWA.
- Deno Deploy / GitHub Pages / any static host — point it at the output directory.
- Your own box — serve the directory behind Caddy or nginx.
Two things to get right on any host but civ start: one response header on the service worker’s versioned route, and an additive deploy — publish each new build alongside the old version directories rather than replacing them, or installs pinned to an older version stop loading entirely. See Deployment for ready-to-use snippets and the reasoning.
Because state lives on the device and sync is user-configured, a plain static host still gives users a persistent, installable, offline-capable, multi-device app with nothing for you to operate. When they want sync, they point the app at a Civility server at runtime (see Sync) — the same bundle works against any server.
The shape of an app #
civ init scaffolds this tree — the same layout every Civility app uses, from a toy to the reference apps (climb, cook, fit):
my-app/
├── civility.json ← build config (name, type, root, outdir, static, icon)
├── deno.json ← imports (@civility/*, lit) + tasks (dev/build/icons)
└── www/
├── index.html ← <header>, <main>, optional <footer>/<ui-bottom-bar>
├── index.ts ← entry: router + client.init()
├── worker.ts ← service worker: init([...plugins])
├── store/app.ts ← app state, exported as a singleton
├── routes/*.ts ← one LitElement custom-element per page
└── static/ ← civility.css, utilities.css, theme.css, icons
Three conventions hold every app together.
One app store, exported as a singleton
www/store/app.ts owns your state and fans changes out to the UI. Routes come and go; the store outlives them, so components import the singleton and subscribe to it.
// www/store/app.ts
import { Store } from '@civility/store'
import { IDBStorage } from '@civility/store/idb'
const APP_ID = 'me.example.myapp'
const store = new Store(new IDBStorage({ dbName: APP_ID }), {
documents: ['settings'],
collections: ['items'],
versions: [{
version: globalThis.__CIVILITY__?.version ?? '0.0.0',
schema: appSchema,
}],
})
export class App {
settings = store.document<Settings>('settings', { theme: 'auto' })
items = store.collection<Item>('items')
#subs = new Set<() => void>()
constructor() {
store.subscribe(() => this.#notify())
this.#init()
}
async #init() {
await this.settings.preload()
await this.items.preload()
this.#notify()
}
#notify() {
for (const cb of this.#subs) cb()
}
addEventListener(fn: () => void) {
this.#subs.add(fn)
}
removeEventListener(fn: () => void) {
this.#subs.delete(fn)
}
}
export default new App()
The store turns every change — local or pulled from sync — into one “something changed” signal. globalThis.__CIVILITY__.version is injected by civ build, so your schema version tracks releases automatically. See Store for Document / Collection / Property and schema migrations.
A route is a custom element
Routes are plain Lit elements rendered into the light DOM (createRenderRoot() { return this }) so they inherit the app’s global CSS instead of being isolated by shadow DOM. Each subscribes to the store and re-renders on change:
// www/routes/home.ts
import { html, LitElement } from 'lit'
import app from '../store/app.ts'
class HomePage extends LitElement {
override createRenderRoot() {
return this // light DOM: civility.css / theme.css apply
}
#onChange = () => this.requestUpdate()
override connectedCallback() {
super.connectedCallback()
app.addEventListener(this.#onChange)
}
override disconnectedCallback() {
app.removeEventListener(this.#onChange)
super.disconnectedCallback()
}
override render() {
return html`<ui-card>${app.items.value?.size ?? 0} items</ui-card>`
}
}
customElements.define('home-page', HomePage)
Wire routes together with createLayoutRouter from @civility/ui, which swaps one element into a <main> landmark per route and manages headers and nav:
// www/index.ts
import { createLayoutRouter } from '@civility/ui'
import { client } from '@civility/workers'
import './routes/home.ts'
import './routes/settings.ts'
client.init()
const { ready } = createLayoutRouter<NavMeta>({
router: { selectorAttrib: 'data-route', useHash: true },
defaultRoute: '/',
landmarks: { main: 'main', header: 'body > header' },
routes: {
'/': { main: () => ({ tag: 'home-page' }) },
'/items/{id}': {
main: (ctx) => ({
tag: 'item-page',
attrs: { 'item-id': ctx.params.id },
}),
},
'/settings': { main: () => ({ tag: 'settings-page' }) },
},
})
ready()
Hash routing (useHash: true) means deep links need no server rewrite rules — that’s what makes the drag-and-drop deploy work. The UI guide covers the router, the ui-* / civ-* components, and styling.
A service worker precaches the shell
www/worker.ts makes the PWA open with no network:
// www/worker.ts
import {
init,
versionedUrl,
withCleanup,
withFetchStrategy,
withPrecache,
withUpdatePolling,
} from '@civility/workers'
init([
withPrecache([
'/',
'/index.html',
versionedUrl('/dist/index.js'),
'/manifest.json',
'/static/theme.css',
]),
withCleanup(),
withUpdatePolling(),
withFetchStrategy(),
])
civ build writes the bundle to a fresh version-namespaced directory every build; versionedUrl resolves /dist/index.js to that build’s actual path, which is what makes updates reliable no matter how a host caches static files.
Other worker plugins: withBlobProxy({ dbName, pathPrefix?, blobStore? }) serves blob bytes to <img> / fetch from the local store (see Blobs), withBackgroundPrecache(metaUrl?) warms the cache in the background, and withFetchStrategy(handler) takes a custom strategy — cacheFirst, networkFirst, or staleWhileRevalidate.
On the client side (index.ts), @civility/workers exposes client:
import { client } from '@civility/workers'
client.init() // register the service worker
client.startUpdatePolling() // prompt on a new version
client.configureBlobProxy({ serverUrl, token }) // pair with withBlobProxy
client.isInstalled() / client.isPWASupported() / client.applyUpdate()
For the update prompt, drop <civ-version> (@civility/ui) into the app shell — it checks for and applies updates with no further wiring. client.applyUpdate() is there if you build your own update UI.
Features #
Each capability is a package you opt into. Start with local state; add the rest as you need them.
| Guide | Package | What it gives you |
| ––––––––––––––––––– | –––––––––– | ———————————————————————————— |
| Store | @civility/store | Reactive local state — Document, Collection, Property, schema migrations. |
| Blobs | @civility/blobs | Content-addressed binary storage (images, files) that dedupes automatically. |
| Sync | @civility/cloud | Push/pull your stores to a Civility server across devices — plus E2E encryption. |
| Services | @civility/services | Call AI and other credentialed services — bring-your-own-key or host bundle. |
| UI | @civility/ui | The layout router, ui-* / civ-* web components, and the CSS / theming system. |
store and blobs work on their own for local-only apps; sync builds on one or both to add cross-device replication and encryption.
Shipping an app also involves:
- Registration — app id, manifest, and origin verification for the consent popup.
- Deployment — the one response header your static host must set.
- Testing — in-memory model tests and integration tests against a real server.
Recipes #
Complete shapes for common apps, each built from the features above:
- Todo list — a single
Collection, the “hello world” of Civility apps. - Journal — dated entries plus a settings document.
- Gallery — images with
@civility/blobs, synced lazily. - AI-powered — structured generation with
@civility/services. - Public share links — publish a document as a read-only link with
@civility/cloud.
Standalone HTML demos of the UI components live in examples/.