A monorepo pairing an Umbraco CMS backend with a Next.js frontend that renders content via Umbraco's Delivery (Content) API.
apps/
umbraco/ Umbraco 18 CMS (.NET 10, SQL Server) — runs in Docker
web/ Next.js 16 app that fetches & renders Umbraco content
mock-d365/ Stand-in for D365 Customer Insights — member data + segments
packages/
ui/ Design system: shadcn/ui components, Storybook, shadcn registry
typescript-config/ Shared tsconfig bases
The workspace is managed with pnpm + Turborepo.
This is a reference implementation, not a product. It exists to answer specific architectural questions (below) and is wired for local development throughout: the Delivery API is unauthenticated, secrets in the committed
.env.examplefiles are placeholders, and the back-office credentials are published in this README on purpose. Read Gotchas worth knowing before taking any of it near a real environment — particularly if you reuse the content-gating code, which depends on a setting whose name gives no hint that access control relies on it.
- Can Umbraco power a Next.js app, headlessly?
- Can D365 drive Engage personalisation, headlessly?
- Can new D365 segments appear without Umbraco code changes?
- Can we demonstrate a Storybook and component registry?
- Yes. Built and verified.
- D365 — source of truth for members and segments
- Next.js — web app and member portal. Owns authentication, identity, signs member claims, renders content
- Umbraco — manages the content
- Engage — owns the rules, picks the variant
- The Next.js renders based on the Content Delivery API. It never chooses what to render itself, it is API bound.
- The CMS stores no member data
- Express on
:3001, standing in for D365 Customer Insights - Four members: student, junior, senior, retired
- Metro, regional and remote locations
- Endpoints: segments, member by email, member by id
- JSON re-read per request — edits apply instantly
- Login authenticates against the CRM
- Signed cookie session. No auth library, no database
- Every request carries signed
X-Member-Claims - Umbraco verifies, and fails closed
- Member data asserted per request, never stored
- Implements Engage's
ISegmentRule/ISegmentRuleFactory - Sits alongside the 13 native rules
- Editor extends Engage's own base element
- Segment list read live from the CRM
- New CRM segment: no code, no sync job
- Segment-varying property drives a resource block
- Distinct set per segment, default for anonymous
- Ten plausible membership resources
- Variants authored in Umbraco, committed to git
- One segment, two rules: D365 AND TimeOfDay
- Rules are ANDed
- Right member, wrong time: no match
- Wrong member, right time: no match
- D365 is a parameter, not a parallel system
- No sync job. Segment list read on demand
- Retired CRM segment flags the rule stale
- Names the missing code, ignores valid ones
- CRM outage flags nothing
- Not personalisation. Personalisation picks a variant and degrades to the default; gating decides readability and denies when unsure
- Umbraco's own Public Access, enforced by the CMS — not by the frontend
- Protect a section; every page beneath it inherits it
- Umbraco owns the access tiers. D365 segments are one way to be granted one
- No Umbraco members exist: membership is asserted per request, verified, and fails closed
- Gated pages vanish from navigation, listings and search; a direct hit is a 404, not a 403
- In git — content, types, segment variants, access-group mapping (uSync)
- Not in git — Engage segments, rules, personalisations, member groups, access rules
- Fresh clone: variants, but no personalisation and nothing gated. Silently
- Hence
seed.sh— two scripts, idempotent, documented
- Docker (for the CMS)
- Node.js >= 24 and pnpm 11 (
corepack enableto get pnpm)
Umbraco Engage is a commercial add-on. Personalisation — the custom segment rule, the variants, the composed D365 +
TimeOfDaysegment — depends on it, so check Umbraco's licensing before running this anywhere other than a local machine. Everything else here (headless Delivery API, content gating via Public Access, preview, cache revalidation, the design system) is core Umbraco and needs no licence.
The CMS runs as a Docker container using SQL Server for storage. Umbraco installs itself
unattended on first boot, and uSync imports the schema and
content committed under apps/umbraco/uSync — so a fresh clone rebuilds the full site.
pnpm cms:upOnce it's running, the backoffice is available at:
- URL: http://localhost:8080/umbraco
- Email: admin@example.com
- Password: ChangeThis1234!
These are the default unattended-install credentials baked into the
Dockerfile. Override them for anything real by passing env vars todocker run, e.g.-e Umbraco__CMS__Unattended__UnattendedUserPassword="...".
The database runs in its own db service (Azure SQL Edge), with these directories mounted:
_mssql/→/var/opt/mssql— the SQL Server data files (git-ignored, machine-local)._data/→/app/umbraco/Data— media + temp/cache (git-ignored, machine-local).uSync/→/app/uSync— schema & content as files (committed to git). uSync imports these at startup, so deleting_mssql/and restarting rebuilds the site fromuSync/.
Required once on a fresh database. uSync covers content — including the per-segment
content variants (the Segment="engage_personalization_N" attributes in
apps/umbraco/uSync/v18/Content/*.config) — but it does not cover Umbraco Engage's own
configuration, nor member groups and Public Access entries. Engage segments, their rules, the
"applied personalisation" records that make a variant live, the member groups used for gating and
the access rules themselves are all rows that exist only in the database.
Skip this and the demo fails silently: the content variants are there, but every page renders the default, and nothing is gated.
pnpm cms:seed
pnpm cms:restartseed.sh runs two idempotent scripts — engage-personalisation.sql (which variant) and
access-groups.sql (who may read at all), kept separate because they configure different things.
Together they create:
- four Engage segments (
Junior Doctor,Student,Senior Doctor,Retired Doctor), plus the composedJunior Doctor in working hours - one
D365Segmentrule per segment, matching a CRM segment code - applied personalisations wiring each segment to a reusable block under Settings > Blocks
- three member groups (
Clinical Archive,Retired Members,Students) and a Public Access entry restricting the Clinical Archive section to the first of them
Rebuild the Delivery API index after seeding. The scripts write access rules straight to the database, which bypasses the cache-refresher notification Umbraco would normally fire, so the content index keeps its stale "unprotected" flag and gated pages go on appearing in list queries until it is rebuilt: Settings > Examine Management >
DeliveryApiContentIndex> Rebuild. Setting the same restriction through the back office needs no rebuild — this is a consequence of seeding by SQL, not of gating.
Do not renumber the applied-personalisation ids. Umbraco addresses a variant by the alias
engage_personalization_<N>, where N is the applied-personalisation id — not the segment id. There is one record per (segment, node), and the nodes here are blocks:
block junior student senior retired Resources for you 1 2 3 4 Resources in working hours 10 (composed D365 + TimeOfDay)— — — Engage also supports a MultiPage type — one record covering several nodes, so one alias per segment. It looks tidier, but a MultiPage record is not clickable in the back office, which costs editors the side-by-side "variant next to the original" editor. Hence one record per node, named
<Segment> – <Block>so the list reads clearly.Those aliases are committed in the uSync content files, so the seeder pins the ids. Content node ids are resolved by GUID rather than hardcoded, since they differ on a fresh install. Variants are written with the default language's ISO code — a NULL culture is silently never resolved.
Engage's configuration isn't visible in git, so reading the database directly is often the
quickest way to understand what state it's actually in. sqlcmd runs from a throwaway
container — nothing to install (slow to start on Apple Silicon, it runs under emulation):
docker run --rm -i --platform linux/amd64 --network umbraco_default mcr.microsoft.com/mssql-tools /opt/mssql-tools/bin/sqlcmd -S db -U sa -P 'ChangeThis1234!' -d UmbracoEngage -W -s '|' -Q "SELECT id, name FROM umbracoEngagePersonalizationSegment ORDER BY id"The tables that matter: umbracoEngagePersonalizationSegment, …SegmentRule,
…AppliedPersonalization, and …AppliedPersonalizationUmbracoPageVariant. Pass long or
multi-statement SQL via a mounted file with -i rather than -Q — -Q mangles it.
The Next.js app needs to know where the CMS and CRM live, plus two signing secrets:
cd apps/web
cp .env.example .envThen generate the secrets and set them in .env:
openssl rand -base64 32 # SESSION_SECRET
openssl rand -base64 32 # MEMBER_CLAIMS_SECRETMEMBER_CLAIMS_SECRET signs the X-Member-Claims token that tells Engage who the visitor
is, so Umbraco must be given the same value:
cd apps/umbraco
cp .env.example .env # then set D365_CLAIMS_SECRET to the same string
docker compose up -d # picks it up as D365__ClaimsSecretIf the two do not match, verification fails closed and everyone sees the default content.
From the repo root — Turborepo starts both the web app and the mock CRM:
pnpm install
pnpm dev- Web app: http://localhost:3000
- Mock D365: http://localhost:3001
The web app resolves the request path against Umbraco's Delivery API
(/umbraco/delivery/api/v2/content/item/{path}), so the CMS from step 1 must be running.
Authentication is the Next.js app's job, not the CMS's. There is no auth library and no database:
a member signs in against the CRM, and the result is a signed JWT in an httpOnly cookie following
the pattern Next documents for this version.
The session payload carries only the CRM member id — the profile is always fetched live, so moving
a member between segments in members.json takes effect on their next request.
/login— lists the demo members, so whoever is driving the demo can sign in as any segment without memorising addresses. Obviously not something a real sign-in page shows./portal— the member's own record and their current segment, read live from the CRM.
Two demo-specific notes. Every account uses the password password, and the credential check is a
stand-in: the member must exist in D365 and the password is the same for everyone. A real build
would use an email magic link or Entra — this stands in for that without pretending to be it.
Signing in also mints a fresh Engage visitor server-side, which matters more than it looks: Engage caches evaluated rule results against the visitor profile, so reusing one visitor id across logins would serve the previous member's variant to the next person on a shared browser.
Sign in at http://localhost:3000/login. Every account uses the password password; the
sign-in page lists the demo members. Authentication is against the CRM — the member must
exist in apps/mock-d365/src/data/members.json.
| Member | Segment |
|---|---|
| aisha.rahman@example.com | Student |
| tom.nguyen@example.com | Junior Doctor |
| priya.venkatesan@example.com | Senior Doctor |
| margaret.whitlock@example.com | Retired Doctor |
- The CRM is the source of truth for member data and segment membership.
- On each Delivery API request the web app mints a short-lived, signed
X-Member-ClaimsJWT carrying the member's segment, location and registration date. - A custom
D365Segmentsegment rule in Umbraco (apps/umbraco/MyDockerProject/Personalization/D365/) verifies that token and matches it against the segments configured on the rule — so Engage decides which variant applies, not the frontend. That means a D365 segment can be combined with native Engage parameters such as time of day. - Engage returns the matching content variant, and the app renders it.
Member data is never persisted by the CMS — it is asserted per request and verified.
The per-segment variants are not on the pages that show them. They live on a reusable block under Settings > Blocks, and a page points at one with its Resource block property:
Settings (a second site root — sibling of Home, so never in the menu)
└── Blocks
├── Resources for you <- 4 segment variants; used by Home AND Search
└── Resources in working hours <- the composed D365 + TimeOfDay variant; used by About us
Two things follow, and they are the point of the design:
- Authoring is per block, not per page. An Engage applied personalisation is per (segment,
node), so putting the varying property on the block means one set of records covers every page
that references it — a page that wants the block costs nothing. Put the varying property on the
pagedoctype instead and each page needs its own set: four more seed rows and four more hand-picked resource lists per page, and every new segment touches every page. - The page document is identical for every visitor, because the reference to the block does not vary by segment — only what it points at does. That is what makes the page cacheable; see Caching below.
The block's heading and intro vary by segment too, so the wording for each audience is authored in the CMS rather than branched on in the front end. The default variant is what an anonymous visitor sees.
Gating and personalisation look similar and are not the same thing, so they are built as two separate mechanisms sharing one trust anchor:
| personalisation | gating | |
|---|---|---|
| decides | which variant you see | whether you may see it at all |
| when unsure | shows the default | denies |
| owned by | Engage segments and rules | Umbraco Public Access |
| gets it wrong | wrong heading | data breach |
Extending the Engage rule to do gating would have been the obvious move and the wrong one: a rule designed to degrade gracefully to "show the default" is not a gate.
Gating is Umbraco's native Public Access — right-click a node, Restrict Public Access, pick member groups. Three things follow:
- It inherits. Protecting the section protects every page beneath it, so adding a page to a gated section needs no configuration at all.
- The CMS enforces it, not the frontend. Both the item endpoints and the query endpoint honour it, so gated pages are absent from navigation, breadcrumbs and listings without the frontend filtering anything — and any search built on the same query inherits that for free.
- A wrong-audience request is a 404. Umbraco answers 401/403; the frontend (lib/umbraco.ts) collapses both into the same not-found a nonexistent page gets, because the existence of restricted material is itself something the wrong audience should not learn.
The one non-standard part is who Umbraco thinks you are. Normally Public Access resolves the
caller from an Umbraco member session, and there are no Umbraco members here — identity lives in
D365 and the session lives in the Next.js app. So
IRequestMemberAccessService
is replaced with one that reads the same signed X-Member-Claims token the segment rule uses.
Umbraco's own rules still do the deciding; only the answer to "which groups is this caller in"
comes from elsewhere. No member data is stored in the CMS.
An access tier is an editorial concept; a segment is a CRM concept. Binding them 1:1 would mean a tier for staff, the board, or a partner organisation could not exist. So Umbraco owns the tiers and D365 is one way to be granted one:
Settings > Access Groups
└── Clinical Archive memberGroups: "Clinical Archive"
d365Segments: "senior-doctor, junior-doctor"
The member group is what editors pick in Restrict Public Access; the codes are what grant it. A group with no codes is a perfectly valid tier that D365 simply does not grant.
The segment codes are a live picker, not typing. d365Segments uses a custom property editor
(App_Plugins/D365SegmentPicker) that reads the CRM through the same authenticated proxy as the
Engage rule editor, so a segment added in D365 is tickable here on the next open with no code
change and no sync job. It stores a plain comma-separated string, which is why the uSync file
still reads senior-doctor, junior-doctor and the server-side parser never changed.
It deliberately never drops a code it does not recognise. A code the CRM no longer returns — retired, or the CRM is simply down — stays ticked and is flagged (not in D365) rather than disappearing from the list. Rebuilding the value from whatever the CRM happened to return would mean an editor who opens this page during an outage and hits Save silently rewrites who can read gated content.
Member groups are still named rather than picked, because Umbraco stores member group names in its own access rules too, and a group picker stores database ids that differ on every install. A name matching no group is logged as a stale reference and grants nothing — the same treatment stale D365 codes get on a segment rule.
The Clinical Archive section is restricted to senior and junior doctors.
| signed in as | in the menu? | direct URL |
|---|---|---|
priya.venkatesan@… (senior) |
yes | page renders |
tom.nguyen@… (junior) |
yes | page renders |
margaret.whitlock@… (retired) |
no | not found |
aisha.rahman@… (student) |
no | not found |
| nobody | no | not found |
Verified fail-closed for a tampered signature, a token signed with the wrong secret, an expired token, and a valid token carrying no segments — all get nothing, on both endpoints.
Personalised does not mean uncacheable, and neither does gated. All three are separated:
| cached? | how | |
|---|---|---|
| Page body, navigation, breadcrumbs, resource listing | yes | use cache + cacheTag, keyed by audience, invalidated when Umbraco publishes |
| The personalised block | no | fetched per request inside a <Suspense> boundary, streams in |
| The signed-in member's name and portal details | no | read live from the CRM |
Gating means these responses are no longer identical for everyone, so they are cached per audience — the sorted set of D365 segments the visitor holds — rather than once for the site. That is a handful of entries (one per distinct segment combination, plus anonymous), not one per visitor.
The audience is passed into every cached fetch as an argument, and that is a deliberate safety
property rather than a style choice: a use cache scope cannot read cookies or headers, so the
audience physically cannot be read ambiently in there. Passing it in is the only option available,
which means the cache key can never be silently omitted. See
lib/audience.ts.
There is a real cost to that, and it is worth being straight about: a menu whose contents depend
on who is asking cannot be part of a static shell. Resolving the audience means reading the
session, and awaiting that in the root layout would make every route dynamic — /_not-found
included, which fails the production build outright. So the header and footer menus sit behind
their own <Suspense> boundaries and stream, the same way the account area already did. See
components/site-navigation.tsx.
cacheComponents is enabled, so every page is a partially prerendered shell with dynamic holes
where the request matters: the menus, the account area, and the personalised block. A warm page
render makes no Delivery API call for the page body at all — you can stop Umbraco entirely and
cached pages still serve, losing only the holes.
Freshness is pushed, not polled: publishing or unpublishing fires
apps/umbraco/MyDockerProject/Revalidation/NextjsRevalidation.cs, which POSTs to
/api/revalidate, which calls revalidateTag. So "cached" here never means "stale until
redeploy" — it is the same contract ISR always had.
Registered as a notification handler in C# rather than an Umbraco webhook on purpose: a webhook is a database row created in the back office, invisible to git and absent on a fresh clone.
- Move a member between segments. Edit
segmentinapps/mock-d365/src/data/members.json. The data files are re-read per request, so the change takes effect on the next page load with no restart and no CMS change. - Add a new CRM segment. Add an entry to
apps/mock-d365/src/data/segments.json. It appears immediately in Engage's segment builder as a selectable D365 segment — the rule's picker reads the CRM live, so no Umbraco code change or sync job is involved. Creating an Engage segment that uses it is then normal editor work. - Retire a CRM segment that a rule still references. Remove a code from
segments.jsonand restart Umbraco. The rule setsHasStaleReferences, logs a warning naming the missing code, and the back office marks it(not in D365)rather than leaving a rule that silently never matches. Only the unknown codes are reported — valid ones in the same rule are left alone — and if the CRM is simply unreachable nothing is flagged, so an outage doesn't masquerade as a hundred stale rules.
The Junior Doctor in working hours segment on the About us page carries two rules:
D365Segment = junior-doctor and TimeOfDay. Rules on a segment are ANDed, so it matches
only a junior doctor, and only inside the window:
| visitor | inside window | outside window |
|---|---|---|
tom.nguyen@… (junior-doctor) |
matches | no match |
priya.venkatesan@… (senior-doctor) |
no match | no match |
This is the point of the integration: a D365 segment is a parameter Engage can combine with
its own, not a parallel mechanism. Adjust the window with @windowFrom / @windowTo in the
seeder — TimeOfDay is evaluated against server time, and the container runs UTC, so
00:00–12:00 there is roughly 10:00–22:00 Australian eastern time.
Each of these fails quietly rather than loudly. All are handled in the code, but they are easy to reintroduce, so the reasoning is recorded here.
Public Access does not filter list queries unless member authorization is enabled. The most
dangerous setting in this repo, because its name gives no hint that content protection depends on
it. Umbraco enforces Public Access asymmetrically: the item endpoints always check it, but the
query endpoint filters the content index only when
Umbraco:CMS:DeliveryApi:MemberAuthorization is enabled —
ApiContentQuerySelectorBuilder.Build wraps AddProtectedAccessQuery in
if (MemberAuthorizationIsEnabled()), and QueryContentApiController runs no per-item check on
its results. With it off, ?fetch=children:/ returns protected pages in full, body markup
included, to anonymous callers. Measured, not theorised. The flow itself is unused — enabling it
is purely what switches the filtering on. Changing the setting invalidates the Delivery API index;
until it is rebuilt, list queries return nothing (fails closed, but looks like the site lost its
content).
Gating is only as good as the Delivery API's reachability. DeliveryApi:PublicAccess is true
and the API key is a placeholder, so anything that can reach Umbraco can read the ungated tree
directly. That is fine for a local demo and not fine anywhere real: put the Delivery API behind
an API key and/or a private network before gating anything sensitive, and rotate
D365:ClaimsSecret — that shared secret is the trust anchor for the whole gate.
Engage bot-filters by user agent. Node's fetch sends user-agent: node by default.
Engage classifies visitors by user agent and returns unpersonalised content for anything it
reads as a bot — silently, with a 200. Server-side Delivery API calls therefore forward the
real browser's user agent (see FALLBACK_USER_AGENT in app/lib/umbraco.ts). Symptom if you
break it: personalisation works via curl but never through the app.
Picked items need expand. The Delivery API returns MultiNodeTreePicker items with their
name and route but an empty properties object unless the property is expanded. The
resource cards would lose their summary and category. Hence ?expand=properties[resources] on the
block fetch. The page fetch deliberately does not expand its block reference — it only needs the
guid, and expanding it there would pull personalised content back into the cached response.
Next's dev HMR fetch cache ignores no-store. Per Next's own docs the Server Components
HMR cache "applies to all fetch requests, including those with the cache: 'no-store'
option". Our Delivery API calls differ only by request headers, not URL, so the first
anonymous response gets replayed for every signed-in member — personalisation looks broken in
dev while being correct in production. Disabled via serverComponentsHmrCache: false in
next.config.ts.
Engage only personalises for a visitor it already knows. Passing an
External-Visitor-Id it has never seen returns a 404 ("Provided External Visitor Id does not
exist"), and passing none means no personalisation at all. The engage_vid cookie is cleared
on login/logout — so one member's cached rule results can't leak to the next — and a fresh
visitor is minted server-side during sign-in rather than waiting for the client-side
tracker, which would otherwise leave the first page after sign-in unpersonalised.
Back-office preview forwards the segment being edited, so an editor previewing a variant
sees that variant. Forced-Segment is gated to preview only — a public request can never
choose its own segment. Because the variants live on blocks, the Preview button on a block sends
that block's key — /draft detects this and renders the block on its own.
A second site root must not sort above Home. The Delivery API resolves / for path-based
queries (fetch=children:/) from the first root node. The Settings root therefore carries
SortOrder 100: if it sorted above Home, / would resolve to it and the whole site's routing
would follow. Verified: with Settings in place, children:/ still returns exactly the three menu
items.
notFound() returns 200, not 404, in production. A Next bug, not a choice. Calling it while
the static shell is generated makes Next try to cache the 404 with revalidate: 0, which a
validator rejects ("Invalid revalidate configuration provided: 0 < 1") — turning every 404 into a
500, in production only, while dev stays correct. Same faulty check as
vercel/next.js#74660, still present in 16.2.12.
Reaching notFound() inside a Suspense boundary avoids the crash and renders the right not-found
UI, but the status is 200 because the shell is already committed. Revisit when the bug is fixed.
The front end is not hand-rolled markup: packages/ui is a
shadcn/ui-based design system built on
Base UI primitives and Tailwind 4, consumed by the web app through the
@repo/ui workspace alias.
pnpm storybook:dev # http://localhost:6006Every component has a story alongside it (button.tsx / button.stories.tsx), and Storybook is
wired to Vitest with the a11y addon, so accessibility violations surface in the test UI rather than
in review.
The interesting part is that the same source is published as a shadcn registry — 39 items
declared in packages/ui/registry.json — so another project can pull a component in with the
shadcn CLI instead of copying files:
pnpm --filter @repo/ui build # storybook build + registry buildThat runs two scripts worth knowing about:
transform-imports.mjsstages the source intodist/, rewriting monorepo imports (@repo/ui/components/button) into the convention published registries use (@/components/button). Stories and tests are dropped — they are dev-only and never shipped.copy-to-web.mjscopies the built registry into the web app'spublic/, so it is served at<REGISTRY_URL>/r/{name}.json. That is whatREGISTRY_URLinapps/web/.envpoints at, and whatapps/web/components.jsonresolves@repoagainst.
Two conventions in here are worth borrowing regardless of the registry. Components take a render
prop to swap their underlying element (render={<Link href="/" />} turns a Button into an
anchor), and resolveNativeButton in packages/ui/src/lib/utils.ts infers Base UI's
nativeButton prop from that render prop — getting it wrong logs a console error and attaches
the wrong ARIA, and answering it per call site is easy to forget.
| Command | What it does |
|---|---|
pnpm dev |
Run the web app + mock CRM in development |
pnpm build |
Build all apps |
pnpm start |
Start the built apps |
pnpm check |
Lint/format check (Ultracite) |
pnpm fix |
Apply lint/format fixes |
pnpm cms:up |
Build and start the Umbraco + SQL Server stack |
pnpm cms:down |
Stop the stack |
pnpm cms:restart |
Restart just Umbraco (needed after seeding) |
pnpm cms:seed |
Apply the Engage and access-control seed scripts |
pnpm storybook:dev |
Storybook for the design system, on :6006 |
pnpm storybook:build |
Build Storybook and the shadcn registry |
- CMS: Umbraco 18 (.NET 10) with Umbraco Engage, SQL Server, uSync, Docker
- Frontend: Next.js 16, React 19, Tailwind CSS 4, TypeScript
- Design system: shadcn/ui on Base UI primitives, Storybook 10, published as a shadcn registry
- Auth: stateless signed-cookie sessions via
jose(the pattern Next documents for this version) — no auth library or database - Mock CRM: Express 5, reading JSON fixtures per request
- Tooling: pnpm workspaces, Turborepo, Ultracite/Biome
No licence file is present, so this code is all rights reserved by default. Add one before publishing if you intend others to be able to use it.