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 wantread
Contentarticles, team members, a product list — the same for every visitorData Sources
A backend service — the site's own backendaccounts, sign-in, per-visitor data, things members createthis 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: true

Its 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' } },
        ],
      },
    ],
  },
}).fetch

uniweb 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.

$devBackend is 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' }).fetch

The 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:

  1. Your foundation stops knowing it is a demo. It reads through @uniweb/api exactly as it will in production. A component that reads a config block knows; one that calls useRecords() does not.
  2. Nothing demo-shaped ships, because $devBackend is stripped from the build.
  3. 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 full

  • Data Sources — content, which is a different problem

  • Site Configuration — services.backend and $devBackend:

  • The conference template is a worked example of everything above — it scaffolds with services.backend, $devBackend: and a mock/ directory already wired, so you can read a working seed rather than assemble one:

    uniweb create my-event --template conference

    A programme that reads as a static site, and becomes an editable app for the people running the event.