Skip to content
Wiki— browse docs
On this page

Step 5: Let the owner edit the page

Title, subtitle, and a featured list two owners can edit at once — no sync code.

Someone installs your wiki. They want their own title, and a few entries starred on the front page. None of that should mean editing your code, and none of it should mean you building a settings screen.

vvd splits "what an owner picks" into two kinds, and they behave differently at publish time. Step 4 covered the first — customization (theme, fonts): options you declare as data, rendered by the platform's own panel. This step is the second — page state: title, banner, what's starred. That's ordinary shared state, and field.value / field.list give you a typed, convergent document with no wiring at all.

Two owners doing exactly that — here's the shape of it, and running below, two live panes. (The demo names its document via useDocument because this page mounts a tool; your scaffold's src/app.tsx uses useCollabState — the second fence below — so don't paste this one into it:)

the demo below
export const codec = defineStateCodec({
  title: field.value(""),
  featuredIds: field.list<string>([]),
})

function WikiPageState({ document, context }: ToolRenderProps) {
  const coords = { worldId: document.worldId, documentId: document.id }
  const { data, actions } = useDocument(coords, codec)
  const entries = useWorldQuery("documents", { type: "card" })

  const toggleStar = (id: string) => {
    const at = data.featuredIds.indexOf(id)
    if (at >= 0) actions.list("featuredIds").remove(at)
    else actions.list("featuredIds").push(id)
  }

  return (
    <>
      <input
        value={data.title}
        readOnly={!context.canEdit}
        onChange={(e) => actions.set("title", e.target.value)}
      />
      {entries.map((entry) => (
        <button key={entry.id} onClick={() => toggleStar(entry.id)}>
          {entry.name} {data.featuredIds.includes(entry.id) ? "★" : ""}
        </button>
      ))}
    </>
  )
}

Type a title in either pane; star an entry in one and watch it land in the other:

Two owners, one page
Type a title in either side, then star an entry.
Starting the example…

One honest difference between the demo and your real wiki: these two panes name their document through useDocument, because this page mounts a tool rather than an app instance. useCollabState (below) takes the same field shape and resolves the document for you — one per world, per app, per project — so your wiki passes it no coordinates. The merge behaviour you just watched is identical either way; it's a property of the field types, not of which hook opened the document.

src/app.tsx
const { data, actions } = useCollabState({
  title: field.value<string>(""),
  subtitle: field.value<string>(""),
  bannerMediaId: field.value<string>(""),
  sectionOrder: field.list<string>(),
  featuredIds: field.list<string>(),
})
Note:

One room for every template

All site-category apps in a world share one shared-state room — app-state:<world>:wiki, not one named after your app. Switch from the built-in Default template to yours and the page keeps its title and its featured entries; your field names live in that shared namespace on purpose.

canEdit decides whether; customizing decides what it looks like

context.canEdit is the authority on whether this person may change anything — false for a reader, false on a published page. customizing is a different question: is the host's Customize panel open right now? Use it to show chrome (a star button, a dashed underline) so the page looks like a page when nobody's editing it — never to gate whether an edit actually lands.

const { values, customizing } = useAppCustomization()
const editing = canEdit && customizing
Warning:

Don't make canEdit depend on customizing

The first-party site surfaces once did, and closing the sidebar silently stripped and remounted every inline affordance mid-edit. canEdit decides whether; customizing decides what it looks like.

Picking a banner

const media = useHostCapability("media")
const scope = useHostCapability("scope")

const pickBanner = async () => {
  if (!media.pick || !actions) return
  const picked = await media.pick({ worldId: scope.worldId, accept: ["image/*"] })
  if (picked) actions.set("bannerMediaId", picked.id)
}

media.pick is optional — it exists on editing hosts and not on a published one. Guard it, as above, and your button disappears where it can't work instead of throwing. Store the id; resolve it to a URL the way Step 3 did.

None of this exists on the published side

useCollabState and useAppCustomization are both live-only — there's no collab document and no Customize panel on a published page. The published renderBaked gets the frozen values as props instead, and reads them back with the same pure helpers you'd use anywhere else. Step 7 covers exactly what those props look like.

Recap

  • Page state (title, banner, featured) is field.value / field.list shared state — no sync code required.
  • useCollabState resolves the document for you; the merge behaviour is identical to a plain useDocument.
  • canEdit gates whether a write lands; customizing only changes what the chrome looks like.
  • media.pick is optional and live-only — guard it, and store the media id, never a URL.

Next steps