Building a solid-app
A solid-app is a tiny, build-free web app served from a Solid pod at
/public/apps/<name>/. No framework, no bundler, no npm at runtime — plain
HTML + one app.js + style.css. Its data lives on the pod as JSON-LD,
read/written over HTTP with the owner's session. Apps are independent but
interoperate by sharing data models and an intent bus — that's what makes
the collection feel like one OS rather than a pile of bookmarks.
Each app is its own GitHub repo under solid-apps, default branch gh-pages
(served raw + via the store).
1. Anatomy
index.html topbar + #app + <script> tags
app.js the app (vanilla JS)
style.css slate topbar (#2a3140) + your accent
manifest.json PWA manifest + "handles": [intent types]
favicon.svg slate rounded tile + white glyph (see §6)
README.md what it is + the data model
LICENSE AGPL-3.0-only
index.html skeleton — copy this:
<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>NAME — your Solid pod</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="manifest" href="manifest.json">
<link rel="stylesheet" href="style.css">
</head><body>
<div class="topbar">
<span class="brand">NAME</span>
<a class="root" href="../../../" title="Go to your pod root">↑ root</a>
</div>
<div class="wrap"><div id="app"></div></div>
<script src="https://unpkg.com/xlogin"></script>
<!-- <script src="intents.js"></script> if it shares/receives -->
<script src="app.js"></script>
</body></html>
2. Auth (xlogin)
Sign-in is the universal Solid/Nostr login pill. Standard helpers:
const authFetch = (url, opts) => ((window.xlogin && window.xlogin.authFetch) || fetch)(url, opts)
const loggedIn = () => !!(window.xlogin && window.xlogin.id)
const myWebId = () => (window.xlogin && window.xlogin.id) || null
// re-render when sign-in state changes:
document.addEventListener('xlogin', render)
document.addEventListener('xlogout', render)
Reads of /public/... work signed-out; writes (and any /private/... read)
need authFetch + sign-in. Show a sign-in note instead of failing silently.
3. Pod paths
The app sits at /public/apps/<name>/, so resolve everything relative:
const ROOT = '../../../' // pod root
const CONTACTS = new URL('../../contacts/', location.href) // /public/contacts/
const PRIVATE = new URL('../../../private/foo/', location.href) // /private/foo/
4. Data: use the established model, don't invent
Store JSON-LD on the pod with the same ontology + location an existing app
uses, so apps interoperate (don't make a parallel silo — that mistake got the
losos schema:ItemList todo model rejected in favour of wf:Tracker).
| Data | @type / ontology | location |
|---|---|---|
| tasks/trackers | wf:Tracker + ical:Vtodo (summary,status,issue:[]) |
/public/tracker/<slug>-data.jsonld |
| calendar | ical:Vcalendar + ical:Vevent (summary,dtstart,component:[]) |
/public/calendar/calendar.jsonld |
| notes | schema:TextDocument (headline,text,datePublished) |
/public/note/ |
| bookmarks | bookmark:Bookmark (dc:title,bookmark:recalls,dcterms:created) |
/public/bookmark/ |
| contacts | vcard:Individual (vcard:fn,hasEmail,hasTelephone,url=WebID) |
/public/contacts/ |
| messages | ActivityStreams Create{Note} |
/private/chats/<base64url(webid)>/ |
/public/ = shareable (public-read on JSS); /private/ = owner-only. Sensitive
data (chats, places) → /private/. Discover items by listing a container
(ldp:contains) or via the type index (what pilot/hub use).
Read/write helpers:
function ldpContains(doc){ const c=doc['ldp:contains']||doc['http://www.w3.org/ns/ldp#contains']||doc.contains||[]; return (Array.isArray(c)?c:[c]).map(x=>typeof x==='string'?x:x['@id']||x.id).filter(Boolean) }
// PUT with a one-shot container-create fallback (JSS auto-creates parents, but be safe):
async function put(url, doc){
const go = () => authFetch(url,{method:'PUT',headers:{'Content-Type':'application/ld+json'},body:JSON.stringify(doc,null,2)})
let r = await go()
if(!r.ok && (r.status===404||r.status===409)){ await authFetch(CONTAINER,{method:'PUT',headers:{'Content-Type':'text/turtle'},body:''}).catch(()=>{}); r=await go() }
if(!r.ok) throw new Error('save '+r.status)
}
For low-friction utilities, also cache to localStorage and sync to the pod
when signed in (see split, weather).
5. Intent bus (compose by type)
Apps hand off data by agreeing on types, not on each other. Vendor
intents.js, add <script src="intents.js">,
and:
// declare in manifest.json: "handles": ["webid","geo","url","text"]
window.intent.open('geo', `${lat},${lon}`, label) // source → chooser → navigates
const i = window.intent.receive() // target on load → {type,value}|null
Types: webid (person), geo (lat,lon), url, text, event, file. The
chooser lists installed apps whose manifest handles the type (auto-opens if
one). Pure navigation → works on mobile, desktop and web.
6. UI conventions
- Topbar:
background:#2a3140; color:#fff;brand (800 weight, .04em) + an↑ rootlink (href="../../../", faint white). - favicon.svg: a slate rounded tile + a white glyph, so it reads on the
launcher's dark tile:
<svg viewBox="0 0 24 24"><rect width="24" height="24" rx="5" fill="#2a3140"/> <g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" transform="translate(2.4 2.4) scale(0.8)"> …feather-style glyph… </g></svg> - A small toast for feedback; a sign-in note when logged out; pick one accent colour per app.
7. Distribution
- Source of truth is
raw.githubusercontent, NOT jsDelivr — jsDelivr serves stale@gh-pagesfiles with a 200 and purges are unreliable. Fetch fromhttps://raw.githubusercontent.com/solid-apps/<app>/gh-pages/<file>. - Optional app (most apps): add an entry to
solid-apps/bundles/all.jsonld(app:spec/app:label/app:description). The store app then lists it and installs it on-device (raw-fetch + authFetchPUTto/public/apps/<name>/). Do not add it to the Android default set unless it should ship by default. - Updating an installed app: bump nothing — just push to
gh-pages; the user taps Update in the store (raw-first, so no stale-CDN). The store skips dot-prefixed files (JSS 403s aPUTto them; keep your.gitignore).
8. Gotchas (learned the hard way)
- Mobile drag: pointer-drag handles need
touch-action: none, or the browser steals the gesture as a scroll and firespointercancel(this broke window-drag in chrome/win98). - Dot-prefixed files (
.gitignore,.acl) →PUT403 on JSS; installers must skip them. - Self-update: after updating an app's own code, reload the page — the
running tab keeps the old cached
app.jsuntil refreshed. - CORS: JSS reflects the request Origin + allows credentials, so cross-pod
browser requests work; Open-Meteo etc. send
*. - Reachability ceiling: a
localhostpod (the Android app) can't be reached by peers and can't authenticate to remote pods (its issuer islocalhost). Cross-pod delivery/append-ACL needs the pod hosted with a real domain + OIDC issuer. External-data apps (weather/maps) work fine — they only make outbound calls. - GitHub trees API (used for file listing) is 60 req/hr unauthenticated.
9. New-app checklist
git init -b gh-pages; add the 7 files (§1).index.htmlfrom the template;manifest.jsonwiththeme_color+handles.app.js:authFetch/loggedIn, render onxlogin/xlogout, store JSON-LD with an established model (§4).- Slate favicon (§6); a sign-in note; a toast.
gh repo create solid-apps/<name> --public --source=. --push.- Add to
bundles/all.jsonldfor store discovery. - Wire the intent bus if it shares/receives a type (§5).