Directory Quickstart
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/blocksgallery viasrc/blocks/directory-pack-bridge.config.ts.@wabbit/tome-accounts— the claimed-owner tenant a listing'sownerAccountpoints 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:devThat 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).
Paid tiers & the managed tier
Every listing resolves to one of five tiers — unpaid (the fail-secure floor: zero marketing entitlements), claimed, listed, featured, and premier — through a fixed order: tier → active subscription or manual billing → your tenant policy's entitlements[tier] → fail-secure to unpaid on anything missing, expired, or suspended. Only the marketing surface (photos, promotions, rank boost, sponsored placement) is billing-gated — an owner can always correct factual fields (address, phone, hours) at unpaid, no subscription required.
Selling a tier runs through @wabbit/tome-economy's subscription checkout, with a metadata contract the directory layer requires:
createSubscriptionCheckoutAction({
// ...price/customer args per @wabbit/tome-economy
metadata: {
userId, productId, productType, // required by economy
listingId, // REQUIRED by the directory layer
tier, // 'claimed' | 'listed' | 'featured' | 'premier' — never 'unpaid'
},
})Wire directory.registerHandlers.registerEconomySubscriptionHandlers(payload) once from onInit so a completed/renewed/cancelled/failed subscription event stamps the right listing's tier. The starter deliberately leaves this unwired — it has no directory-tier checkout action anywhere yet — so treat it as the step you own once you actually sell a tier, not something to cargo-cult in early.
For a merchant who can't run Stripe (a cannabis-adjacent or otherwise high-risk-classified business — Stripe's own restricted-business policy excludes most cannabis-adjacent marketing/SaaS), @wabbit/tome-economy-authnet is a generic Authorize.net adapter for the same PaymentProviderAdapter contract, built around Authorize.net's portable CIM card vault so a change in underwriting ISO doesn't break every active subscription at once. It carries no cannabis-specific code either — same "vertical rules live in your policy, not the package" posture as the directory layer itself. It is not installed in the starter; add it directly when a merchant needs it.
The fully-managed Directory capability tier — where we run claim review, tier billing, and the compliance posture with you — is set up through a request-access conversation, not a checkout button. If that's where you're headed, reach out. For what a subscription keeps buying you after install, see Versioning & updates.