Directory Quickstart

  1. What you're building
  2. What the starter already gives you
  3. Install the packages
  4. Wire the directory layer
  5. Register the map-led blocks
  6. Seed markets & listings
  7. Claims — owner verification
  8. Paid tiers & the managed tier

This quickstart stands up a local business directory: markets, listings, owner claims, reviews, favorites, and promotions, held by the @wabbit/tome-directory layer, with a map-led front end from @wabbit/tome-blocks-directory-pack (a directory map, a listing grid and detail page, a promotions rail, a claim CTA, a review form, an age-gate explainer, and a mega-menu block). The layer ships zero built-in listing types and zero vertical rules — you register your own defineListingType schema and supply a DirectoryTenantPolicy for your vertical (a generic trades directory, a services marketplace, or a regulated vertical with age-gating and licence verification turned on). It assumes you have the block loop from Get Started working.

The layer runs a live cannabis-dispensary directory in production, but nothing in its source references any regulated vertical by name — every compliance rule (age gate, licence registry, content policy) is data your own site supplies through its tenant policy, never a branch inside the layer.

What the starter already gives you

If you forked tome-starter, a generic trades directory (plumbing/electrical/HVAC — the package README's own quickstart premise) is already wired in src/layers/directory.config.ts and composed in src/payload.config.ts:

  • @wabbit/tome-directory — markets, listings, claims, reviews, favorites, and promotions, with one registered listing type (trades-shop).
  • @wabbit/tome-blocks-directory-pack — the eight directory blocks, previewed in the /blocks gallery via src/blocks/directory-pack-bridge.config.ts.
  • @wabbit/tome-accounts — the claimed-owner tenant a listing's ownerAccount points at.

The starter's live /directory and /directory/[slug] routes are its own route tree over @wabbit/tome-directory's ./server queries — the pack's blocks are wired into the starter's own /blocks gallery for preview only, not into the Pages layout field. Wiring the pack blocks onto a real page is the step you own if you want the map/grid/detail blocks live instead of a hand-built route.

Install the packages

Every package below is private (npm.wabbit.com) — make sure your .npmrc has the registry line and an install token (see Get Started). Unlike the Commerce block pack, the directory pack is a plain dependency, not one of the separately-gated Pro packs — the standard registry token is all it needs.

npm install @wabbit/tome-blocks-core @wabbit/tome-blocks-directory-pack @wabbit/tome-directory @wabbit/tome-accounts

@wabbit/tome-directory also requires @wabbit/tome-economy and @wabbit/tome-workflow as peers (both are non-optional in the package's own peer table), even if you never sell a paid tier — the workflow peer is satisfied at the code level, not by anything you configure (see below). @wabbit/tome-forms and @wabbit/tome-intake are optional peers for a guided claim-submission front end.

Wire the directory layer

Register your listing type(s) first — defineListingType's typeFields get composed into a real ${value}Details group on the listings collection, so a type is schema, not a dropdown label. Then build a tenant policy and call createDirectoryLayer:

import {
  createDirectoryLayer,
  defineListingType,
  defineDirectoryTenantPolicy,
} from '@wabbit/tome-directory'

const tradesShop = defineListingType({
  value: 'trades-shop',
  label: 'Trades Shop',
  labelPlural: 'Trades Shops',
  typeFields: [
    { name: 'trade', type: 'select', required: true, options: ['plumbing', 'electrical', 'hvac'] },
    { name: 'licensedInsured', type: 'checkbox' },
  ],
  requiredRegistry: false,
  detailRoute: '/directory/[slug]',
})

const tenantPolicy = defineDirectoryTenantPolicy({
  ageGate: { enabled: false, minAge: 18, mode: 'dob', logResultNotDob: true },
  registry: { name: 'No registry required', sourceUrl: 'https://example.gov', requireVerifiedLicenseForTypes: [], reverifyEveryDays: 180 },
  content: { prohibitedPatterns: [], requiredDisclaimers: [], allowPriceDisplay: true, imageReviewQueue: false },
  reviews: { requireLogin: true, requireVerifiedVisitForBadge: false, insiderDisclosureRequired: true, moderation: 'post', maxPerAuthorPerListingPerDays: 30 },
  promotions: { requireEndDate: true, paidSurfacesOwnerSourceOnly: true },
  privacy: { storeDob: false, preciseGeolocation: false, adPixelsOnListingPages: false },
  dataRetention: { menuItemTtlHours: 24, gateEventRetentionDays: 90 },
})

const directory = createDirectoryLayer({
  membersSlug: 'members',
  accountsSlug: 'owner-accounts', // see the accounts-slug note below
  membershipsSlug: 'memberships',
  listingTypes: [tradesShop],
  tenantPolicy,
  currentTermsVersion: '1',
})

// ...add to your config's `collections`/`endpoints`:
...directory.collections,
...directory.endpoints,

The accounts-slug collision. The layer's accountsSlug points at an @wabbit/tome-accounts account — and that layer's own default slug is accounts, which collides with better-auth's own accounts collection (OAuth provider links) on any site using tome-core's auth. The starter resolves it on the accounts side: createAccountsLayer({ slugs: { accounts: 'owner-accounts' } }), then passes that same string as the directory layer's accountsSlug. See the Community quickstart for the other way to resolve the same collision.

You do not need to call createWorkflowLayer(). @wabbit/tome-workflow is a required peer because submitClaim/approveClaim and the review-moderation transitions call claimTransition from @wabbit/tome-workflow/server directly — that primitive is a standalone compare-and-swap and does not consult the workflow layer's registry. createWorkflowLayer()/initWorkflow() only self-registers a cosmetic admin-nav entry; the directory layer works with the peer installed and nothing further wired.

Pass access to createDirectoryLayer if your site's staff check isn't a flat req.user.role string — the layer's own default staff bypass (directoryStaff) reads exactly that field, which won't match a roles-array or multi-role scheme. Called with no access override at all, every collection factory fails closed (staff-only CRUD); createDirectoryLayer always supplies the real status-filtered public-read bundle on top of that floor.

Register the map-led blocks

The pack ships eight blocks — directoryMap, directoryListingGrid, directoryListingDetail, directoryPromotionsRail, directoryClaimCta, directoryReviewForm, directoryAgeGate, and directoryMegaMenu. The one-call registration form:

import { blockRegistry, bundleRegistry } from '@wabbit/tome-blocks-core'
import { registerDirectoryBlocks } from '@wabbit/tome-blocks-directory-pack'

registerDirectoryBlocks(blockRegistry, bundleRegistry)

Four of the eight blocks are query-driven (map, listing grid, listing detail, promotions rail) — hydrate them server-side, once per process, via the ./server subpath (never the bare package root, which would pull every block's CSS into a client bundle that only wants registerDirectoryBlocks):

import { registerDirectoryRenderers } from '@wabbit/tome-blocks-directory-pack/server'

registerDirectoryRenderers({ payload, mapProviderId: 'maplibre' })

The default map provider is maplibre (OpenStreetMap tiles, no API key). Mapbox and Google are optional — install mapbox-gl or @googlemaps/js-api-loader and import @wabbit/tome-blocks-directory-pack/maps/mapbox or /maps/google from a client entry point (never from payload.config.ts — those provider subpaths mount a real SDK in the browser) to switch.

Seed markets & listings

The starter has a real seed. Running the demo seeds populates the directory's markets, listings, one demo claim, and a handful of reviews:

npm run demo:dev

That runs with DEMO_SEED=1 SANDBOX_SEED=1 and is idempotent, so re-running is safe. Prefer to start clean? Create a market and a listing by hand in /admin — pick a registered listing type, set the listing published, and it's live at its detailRoute.

Claims — owner verification

Claim submission requires a signed-in session — verified against @wabbit/tome-directory's changelog, effective 1.2.0 (the starter is on 1.2.1). Before that release, POST /directory/claims took the claimant from the request body, so any anonymous caller could file a claim on any listing in another member's name. Since 1.2.0, the endpoint resolves the claimant from the session (req.user → the members row that points at it) and never from the body: no session → 401; a session with no member row → 403; a body-supplied claimantMember that doesn't match the resolved member → 403, never silently rewritten. A server posting on a visitor's behalf must forward the visitor's cookie and x-forwarded-for headers, or the claim records the server's own session/address.

The starter's own directory-claims.create access is hardened further, to staff-only — an ordinary signed-in member can't self-serve a claim from the live site in that build; only staff (via /admin or an authenticated staff session) can create one. That's a starter-specific tightening on top of the layer's own default (any authenticated session), not a layer requirement — widen access.create back toward ({ req }) => Boolean(req.user) if you want a visitor-facing "claim this listing" form.

Once a claim resolves, approveClaim stamps the listing's ownerAccount and grants the claimed tier — free by default (requirePayment: false), or gated behind checkout when you charge for your entry tier (see below).