Skip to content

21. Authors with references

Show who wrote each asset with one reference declared in collection.yaml, a profiles collection keyed by user id, and expand=author(name,avatar) on the list: the cost headers before and after selecting fields, a profile nobody can read, and writes that keep the asset's own ETag.

The gallery shows titles, but not who saved them. The name lives somewhere else: the account (email, password, roles) is private, and the directory of members is business data. The usual fix is one more request per card, or copying the name into every asset and keeping it up to date. A reference is the third way: the asset says where its author is, and a read can bring the author along.

A profile per person

A profile is what other members see of a person. It belongs to its owner (_meta.owner, set by backd), and its id is the person’s own id: Ana’s profile is at /v1/shelf/main/profiles/<Ana's user id>. There is at most one per person because an id is unique, with no index to maintain, and anyone who knows a user id (every document they own carries it in _meta.owner) can read their profile directly.

examples/config/shelf/main/profiles/schema.json

{
  "$comment": "How a member appears to the others (chapter 21): one profile per user. Assets point at it through the `author` reference, so a list can show who wrote each one without a request per card. The profile belongs to its owner (_meta.owner), which is how the reference finds it.",
  "type": "object",
  "properties": {
    "name":   { "type": "string", "minLength": 1, "maxLength": 80, "description": "The name shown next to what the member wrote." },
    "avatar": { "type": "string", "maxLength": 300, "description": "An initial, an emoji or a short text shown beside the name." },
    "bio":    { "type": "string", "maxLength": 300, "description": "One line about the member." }
  },
  "required": ["name"],
  "additionalProperties": false
}

Its rules are short: every signed-in member reads every profile, and only the owner changes their own. The line that does the rest is custom_id: user:

examples/config/shelf/main/profiles/collection.yaml

# Chapter 21: a profile is what other members see of a person. The account
# (email, password, roles) is private; a profile holds only what the person
# chooses to show, so it can be read by every member and expanded into lists.
#
# A profile lives at the person's own id: /v1/shelf/main/profiles/<user id>. backd checks
# that the id of a create is the creating user's, and an id is unique, so there is at most
# one profile per person without an index to keep it so.
custom_id: user

rules:
  # Every signed-in member reads every profile; anonymous visitors none, so the
  # public gallery shows no authors (the reference expands to null there).
  read: user != nil

  # A member makes their own profile; the id says whose, and a second create is a 409.
  create: user != nil

  # Only the owner changes or removes it.
  update: user != nil && document._meta.owner == user.id
  delete: user != nil && document._meta.owner == user.id

# A profile goes with its person when they are erased: what they wrote stays,
# and its author is an unreadable reference (null) from then on.
on_owner_delete:
  action: delete

With custom_id: user backd takes the id of a create from the client and checks that it is the creating user’s: a profile at anyone else’s id is a 400 invalid_id, and a second profile of the same person is a 409 id_taken. Add the two files as config/shelf/main/profiles/schema.json and collection.yaml.

await profiles.create({ id: user.id, name: 'Ana', avatar: '🦊' })   // the typed client requires the id
const mine = await profiles.get(user.id)                              // and finds it by it

Declare the reference

An asset’s author is the person who owns it, so the source is _meta.owner, and the target is the profile whose id is that value: the default key of a reference is id, so nothing more is needed. Add this at the end of config/shelf/main/assets/collection.yaml:

references:
  author:
    field: _meta.owner
    collection: profiles

backd checks it when it starts: the collection exists and the source is something a reference can read. Nothing is stored for the reference: it is a read-time lookup, and a key other than id would have to be a field with a unique index.

Ask for it

A read says which references it wants, and which fields of them:

GET /v1/shelf/main/assets/<id>?expand=author
{
  "id": "…", "title": "The backd handbook", "kind": "link",
  "_meta": { "owner": "…", "version": 1, … },
  "_expanded": {
    "author": { "id": "…", "name": "Ana", "avatar": "🦊", "bio": "…", "_meta": { … } }
  }
}

Without parentheses the whole profile comes back, as a direct read would return it. A gallery card needs two fields, and asking for two keeps the response small:

GET /v1/shelf/main/assets?expand=author(name,avatar)&limit=9

The app does it in one line, with the builder that checks the form:

import { expand } from 'backd-js'

const page = await assets.list({ where, orderBy, limit: 9, expand: expand(['author', ['name', 'avatar']]) })
page.items[0]._expanded?.author   // { id, name, avatar } or null

What it costs

The answer says what the expansion did:

X-Backd-Expand-Queries: 1     one query on profiles, for the whole page
X-Backd-Expand-Docs: 9        nine authors placed

A page of nine cards costs one extra query, not nine. Those numbers don’t change with field selection: choosing name,avatar makes the response smaller, not the lookup cheaper. What makes it expensive is asking for more paths, more levels or a many reference with long arrays; nothing is expanded unless asked, and the server refuses past its limits.

A profile nobody can read

profiles reads need a sign-in. The public gallery of chapter 1 has no signed-in visitor, so there the author is null, and so it is for an author who never made a profile or whose profile was erased. The three look identical on purpose: a reader can’t use the field to find out which of them it was. The card just doesn’t print a name.

Writes are the asset’s own

ETag of an expanded read is still the asset’s version, so If-Match works with it. Expanded responses are never cached or answered 304, and a write never expands: sending back what you read (_expanded included) changes nothing, because _expanded is ignored like _meta.

where and order_by use the asset’s own fields: where={"author.name":"Ana"} is a 400 that says so. To find the assets of one person, filter on _meta.owner.

You should see

  • A second profile for the same member is refused (409 id_taken), and a profile at someone else’s id is 400 invalid_id; the profile’s id is the member’s user id.
  • expand=author returns the profile in _expanded.author; the asset and its ETag are as before, and the response is private, no-cache.
  • With author(name) only id and name come back; X-Backd-Expand-Queries is 1 for one asset and for a whole page.
  • An unknown reference is 400 invalid_expand that names it; where on author.name is 400 invalid_query.
  • The gallery cards show by Ana; signed out they show no author.

Read Expanding references for the limits and the client for typed _expanded.

Next: chapter 22 — search and a team dashboard.

Edit this page · Last updated 13 Nov 2026