Sites with Accounts
Some sites are read by everyone and edited by their author. Others have members — people who sign in, have something of their own, and create content the site shows back to them. A course catalogue where learners track progress. A conference programme the organisers edit and attendees check into. A members' directory only members can see.
This guide is about the second kind: what to declare, how to build it before any server exists, and how the same site behaves without one.
Audience: foundation developers building components that need a signed-in visitor, and site developers wiring one up.
This is not the same as fetching data
Two different things are easy to confuse, and they have separate guides:
| you want | read | |
|---|---|---|
| Content | articles, team members, a product list — the same for every visitor | Data Sources |
A backend service — the site's own backend | accounts, sign-in, per-visitor data, things members create | this guide |
A site can have both, one, or neither. They are declared separately and nothing about one implies the other.
Declaring it
On a site you publish, one entry asks your host for the backend service:
# site.yml
services:
backend: trueIts address comes from your host, and components never read it. They ask @uniweb/api, which reads it for them — so the same foundation works on a site with the service and on a site without it, with no branch in your build. A backend you run yourself is an address instead — backend: https://your-backend.example/_api under services: — and asks your host to leave its own off.
Building before the server exists
You do not need the service running to build against it. Name a local handler:
services:
backend: true # in production, your host's
$devBackend: ./mock/api.js # in `uniweb dev`, this answers it// mock/api.js
import { createMockBackend } from '@uniweb/api/mock'
export default createMockBackend({
seed: {
accounts: [
{ username: 'ada', password: 'ada', operator: true }, // runs the site
{ username: 'guest', password: 'guest' }, // a member
],
// A Model's sections, so writes are checked the way production checks them —
// `append_only` included. Who may edit an entry is enforced either way.
schemas: {
'@/track': { // your foundation's own type, named as `uniweb dev` sends it
sections: {
identity: { kind: 'single', brief: true, fields: { name: { type: 'string', required: true } } },
sessions: { kind: 'multi', fields: { title: { type: 'string', required: true } } },
},
},
},
entities: [
{
uuid: '01926d5e-0000-7000-8000-000000000001', // a UUID, as on the wire
model: '@/track',
items: [
{ section: 'identity', data: { name: 'Main hall' } },
{ section: 'sessions', data: { title: 'Opening keynote' } },
],
},
],
},
}).fetchuniweb dev answers your site's backend service with it, at an address the dev server supplies — your site never names one, so nothing in your foundation knows which backend it is talking to. Because it is same-origin, sign-in cookies behave as they will in production.
Anything can sit behind $devBackend — a hand-written stub, recorded fixtures, your real service running as a function. The mock above is the one that speaks this client's own dialect.
$devBackendis never published. Keys beginning with$are local to your working copy and are stripped from the built site, so a visitor cannot reach your local handler and a build cannot ship it by accident.
It enforces, so your demo cannot lie
The mock applies what production applies — who may edit an entry, which sections your schemas declare insert-only, and that the account running the site reads everyone's entries but changes them only after confirming its password. A permission you are relying on fails here, at your desk, rather than in front of a user. That is the part a hand-rolled fixture object usually gets wrong: it shows a button working that production would refuse.
The mail the backend would send — a sign-up's confirmation, a password reset — is printed in the dev server's terminal, its link on your site, so following it tries your own page.
State is in memory. Restart to reset.
Opening already signed in
A demo whose subject is the signed-in view has to open in it. Otherwise the first thing a visitor meets is a login form for an account they have to be told about.
export default createMockBackend({ seed, signedInAs: 'ada' }).fetchThe session it opens is a real one — reads are authorized, and a normal sign-out ends it. It throws if the username is not seeded, rather than opening anonymous, because a typo otherwise produces "why am I not signed in?" with no visible cause.
Development-only by construction: the whole mock module is.
Writing the components
The client's own README is the reference — sessions, useRecords, useEntity, useEntityWriter, the SignedIn / SignedOut gates, and how concurrency and conflicts are handled: @uniweb/api.
Two habits worth forming early, both of which the README explains in full:
Ask whether the site has the service before you draw. It is a synchronous read of the site's own configuration, not a probe:
import { isBackendEnabled } from '@uniweb/kit'
if (!isBackendEnabled()) return <StaticVersion />Every site service is asked the same way — isSearchEnabled(), isSubmitEnabled(), isTrackingEnabled(), isAssistantEnabled(). One predicate per service, no arguments, and false always means the same thing: draw nothing.
Treat "no source" and "nothing there" as different answers. absent means there is no live source — no backend service, or nobody signed in. ready with an empty list means the service answered and there is nothing. Showing "nothing yet" for the first tells a visitor their content is missing when it was never asked for.
Finish the links the backend mails where they land. A sign-up's confirmation, a password reset and an invitation each arrive as a link to the site's home page, a token in the address. A component the home page renders — a header or a layout is on every page — calls useMailedLink(), which says which step the link came for and finishes it.
Let an administrator act only after confirming it is them. On a site, an administrator reads what their role reaches and changes nothing through it until they confirm their password; then, for a while, they act through the role. useElevation() says whether to offer that, does it, and says until when it lasts — draw the controls that remove or change other people's content from it, and ask each record what the viewer may do to it.
Show the invitations waiting, wherever a member lands. An invitation to join an organization waits for the member's answer, whether it was sent to their account or taken up from a mailed link. useInvitations() lists the ones waiting and answers them — draw it for anyone signed in, not only after a link, since an invitation sent to an account comes with no link to follow.
Let people give access to what they own. Whoever administers an entry — its owner, or someone given admin on it — can give other people read, edit or admin on it, and take it back. An instructor enrols students in a course this way; you name it as your solution does — "enrolled", "course staff". useAccess({ schema, uuid }) lists who holds what and gives and takes it; for everyone else it answers forbidden, the ordinary answer, so draw nothing.
Land what a member creates where it belongs. Everything a member creates lands at the root of the organization the site's backend is founded with, unless the create names one of its units — a department, a course. useUnits() lists the ones the viewer is placed in, and createEntity({ …, unit }) lands an entry in one.
A site without the service
Remove backend from services: and the site still works. isBackendEnabled() answers false, and the features that need the service disappear rather than break.
⛔ When the answer is no, draw nothing — not a disabled button, not an explanation. A visitor has no stake in which services the operator set up, and "sign-in is unavailable" reads like a breakage when it is simply a feature this site does not have.
This is what lets one foundation serve a marketing site and a members' portal without a flag: the components that need a session are simply not rendered on the site that has none.
Where the fixture belongs
If you are building a template or a demo, the fictional tenant — the accounts, the sample roster, the progress a learner has made — belongs in the seed behind $devBackend, not in site.yml and not compiled into your foundation.
Three reasons, and the first is the one that matters:
- Your foundation stops knowing it is a demo. It reads through
@uniweb/apiexactly as it will in production. A component that reads a config block knows; one that callsuseRecords()does not. - Nothing demo-shaped ships, because
$devBackendis stripped from the build. - Deleting it gives you the empty product, not a broken one — the degradation above is the same mechanism.
See also
@uniweb/api— the client, in fullData Sources — content, which is a different problem
Site Configuration —
services.backendand$devBackend:The
conferencetemplate is a worked example of everything above — it scaffolds withservices.backend,$devBackend:and amock/directory already wired, so you can read a working seed rather than assemble one:uniweb create my-event --template conferenceA programme that reads as a static site, and becomes an editable app for the people running the event.