Skip to content

Repository files navigation

next-umbraco-demo

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.example files 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.


What this demonstrates

1 · The question

  • 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.

2 · Architecture

  • 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

3 · Mock D365

  • 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

4 · Identity and claims

  • 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

5 · The custom segment rule (D365 Segment)

  • 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

6 · Personalised content

  • 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

7 · Composability

  • 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

8 · CRM and CMS in step

  • 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

9 · Content gating (access control)

  • 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

10 · Reproducible from git?

  • 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

Prerequisites

  • Docker (for the CMS)
  • Node.js >= 24 and pnpm 11 (corepack enable to get pnpm)

Umbraco Engage is a commercial add-on. Personalisation — the custom segment rule, the variants, the composed D365 + TimeOfDay segment — 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.

1. Spin up the CMS (Umbraco)

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:up

Once it's running, the backoffice is available at:

These are the default unattended-install credentials baked into the Dockerfile. Override them for anything real by passing env vars to docker run, e.g. -e Umbraco__CMS__Unattended__UnattendedUserPassword="...".

Data persistence

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 from uSync/.

2. Seed the Engage and access-control config

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:restart

seed.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 composed Junior Doctor in working hours
  • one D365Segment rule 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.

Inspecting Engage's tables

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.

3. Set up environment variables (web app)

The Next.js app needs to know where the CMS and CRM live, plus two signing secrets:

cd apps/web
cp .env.example .env

Then generate the secrets and set them in .env:

openssl rand -base64 32   # SESSION_SECRET
openssl rand -base64 32   # MEMBER_CLAIMS_SECRET

MEMBER_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__ClaimsSecret

If the two do not match, verification fails closed and everyone sees the default content.

4. Run the apps

From the repo root — Turborepo starts both the web app and the mock CRM:

pnpm install
pnpm dev

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.

Sign-in and the member portal

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.

Personalisation demo

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

How it works

  1. The CRM is the source of truth for member data and segment membership.
  2. On each Delivery API request the web app mints a short-lived, signed X-Member-Claims JWT carrying the member's segment, location and registration date.
  3. A custom D365Segment segment 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.
  4. 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.

Where the personalised content lives

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 page doctype 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.

Content gating (access control)

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.

How it works

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.

Access groups are not D365 segments

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.

Trying it

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.

Caching

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.

Things to try

  • Move a member between segments. Edit segment in apps/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.json and restart Umbraco. The rule sets HasStaleReferences, 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.

Composing a D365 segment with a native Engage parameter

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.

Gotchas worth knowing

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.

Design system, Storybook and the component registry

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:6006

Every 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.

It is also a publishable registry

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 build

That runs two scripts worth knowing about:

  • transform-imports.mjs stages the source into dist/, 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.mjs copies the built registry into the web app's public/, so it is served at <REGISTRY_URL>/r/{name}.json. That is what REGISTRY_URL in apps/web/.env points at, and what apps/web/components.json resolves @repo against.

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.

Available scripts (root)

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

Tech stack

  • 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

Licence

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.

About

A demonstration of a headless next.js and Umbraco implementation

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages