A Kontent.ai sample ASP.NET Core MVC application running on .NET 8, built on the v19 Delivery SDK. It supersedes the legacy .NET sample app and doubles as a reference for the patterns the new SDK was designed around — keyed client registration, webhook-driven cache invalidation, rich-text resolution, iframe-ready preview, and Smart Link click-to-edit overlays.
The app also uses the companion Kontent.Ai.AspNetCore package for the ASP.NET Core–specific pieces: the <rich-text> tag helper for rendering structured rich-text content, the <img-asset> tag helper for responsive images with srcset/sizes, and UseWebhookSignatureValidator middleware for verifying webhook signatures.
It's based on the Kontent.ai Ficto multisite project — three brand subsites (Imaging, Healthtech, Surgical) served from a single deployment with shared navigation and a common content collection for cross-brand pages.
Follow these steps to get the app running locally.
- .NET SDK 8.0 or newer
- A Kontent.ai environment containing the Ficto multisite sample content
- The environment's Environment ID (required)
- A Preview API key (optional — needed to see unpublished drafts)
- A Secure Access API key (optional — needed if Secure Access is enabled on the environment)
In Kontent.ai, create a new project and pick the Ficto multisite template from the sample project gallery. That populates the environment with the content types, taxonomy groups, and items this app expects (WebsiteRoot, Page, Article, Product, Solution, NavigationItem, etc.). Everything in the sample UI — the three subsites, navigation, articles, products — is driven from that content.
- Clone this repository:
git clone https://github.com/kontent-ai/sample-app-net-mvc.git
cd sample-app-net-mvc- Restore .NET dependencies:
dotnet restoreSet your environment ID in appsettings.json:
DeliveryOptions:EnvironmentId— the environment this app reads from.
Note
You can find the environment ID under Environment settings → General.
Note
You can manage preview and secure access API keys under Project settings → API keys → Delivery API Keys.
Note
You can manage webhooks under Environment settings → Webhooks.
Everything else is optional, but recommended for the feature it enables. Store secrets via user-secrets rather than in appsettings.json so they never get committed:
dotnet user-secrets init
dotnet user-secrets set "DeliveryOptions:PreviewApiKey" "<preview-api-key>"
dotnet user-secrets set "DeliveryOptions:SecureAccessApiKey" "<secure-access-api-key>"
dotnet user-secrets set "PreviewOptions:Secret" "<preview-shared-secret>"
dotnet user-secrets set "WebhookOptions:Secret" "<webhook-signing-secret>"| Setting | Required for | Notes |
|---|---|---|
DeliveryOptions:EnvironmentId |
Any content read | The only hard requirement. |
DeliveryOptions:PreviewApiKey |
Preview mode | Without it, preview requests silently fall back to production with a warning. |
DeliveryOptions:SecureAccessApiKey |
Secure Access | Only needed if the environment has Secure Access enabled. |
PreviewOptions:Secret |
Preview auto-enable | Ships as mySecret so preview URLs work out-of-the-box; override for anything reachable. An empty value logs a warning and admits any non-empty ?secret=. |
WebhookOptions:Secret |
Webhook-driven cache invalidation | Required HMAC secret; requests with a missing or mismatching X-Kontent-ai-Signature (or legacy X-KC-Signature) are rejected by UseWebhookSignatureValidator. |
user-secrets values are merged into configuration at runtime and are scoped to your local user profile.
Caution
Storing keys directly in appsettings.json is convenient but risks accidental commit. Prefer user-secrets (above) for local dev and environment variables / Key Vault / a secrets manager for deployed environments.
If you'd rather keep local overrides in a file, drop them in appsettings.Development.json — it is gitignored and loaded automatically when ASPNETCORE_ENVIRONMENT=Development (the default for dotnet run). Values in it override appsettings.json without touching the committed file.
The SiteOptions section (appsettings.json, ships empty) overrides the defaults defined on Services/Content/SiteOptions.cs. The section is bound once via AddOptions<SiteOptions>() in Program.cs and validated on startup. All keys are optional — omit them and the record's property initializers apply.
| Key | Default | Override semantics |
|---|---|---|
CacheExpirationSeconds |
60 |
Replaces the scalar. Sets the production Delivery client's DefaultExpiration; raise it once webhooks are wired to avoid unnecessary re-fetches. |
RouteTemplates |
{ page: "/{slug}", article: "/articles/{slug}", product: "/products/{slug}", solution: "/solutions/{slug}" } |
Merges with the defaults. Specifying "RouteTemplates": { "article": "/blog/{slug}" } overrides just article; the other three keep their defaults. |
Example — custom article URLs and a longer cache window:
Trust the ASP.NET Core dev certificate (one-time, per machine) so HTTPS works without browser warnings:
dotnet dev-certs https --trustBuild and run:
dotnet build
dotnet runThe app is served at https://localhost:7108 (HTTP on :5107 redirects to HTTPS).
Note
Before deploying anywhere reachable, constrain AllowedHosts in appsettings.json to your expected hostname(s). The shipped "*" default is intentionally permissive for local development only.
The Ficto sample is a multisite setup with three spaces (ficto_imaging, ficto_healthtech, ficto_surgical). In production each space is reached via its own subdomain; in local dev, use the ?collection= query parameter instead — it's recognised by SpaceContextMiddleware and persisted in the ficto_space cookie for subsequent navigation:
https://localhost:7108/?collection=ficto_imaging
https://localhost:7108/?collection=ficto_surgical
See Configuring the Kontent.ai preview URL for how the same ?collection= parameter is used to target preview iframes at a specific subsite.
Note
SpaceContextMiddleware also resolves the space from the request's subdomain, so the same subsites are reachable at http://ficto-imaging.localhost:5107, http://ficto-healthtech.localhost:5107, and http://ficto-surgical.localhost:5107 (hyphens in the subdomain become underscores before matching the collection codename). Most modern operating systems resolve *.localhost to the loopback address automatically per RFC 6761; on older Windows setups you may need to add hosts-file entries.
HTTPS is not available on these URLs — the ASP.NET Core dev cert is issued for localhost only, not *.localhost, so browsers reject HTTPS requests to the subdomain. For the same reason they can't be used as Kontent.ai preview URLs (the preview iframe requires HTTPS), which is why the ?collection= query parameter is the recommended approach for local development and the only one Kontent.ai supports in preview URLs.
The app is a content-rendered website for the fictional "Ficto" brand — three subsites sharing a common backbone. It exists as a learning reference for integrating Kontent.ai with ASP.NET Core MVC.
SpaceContextMiddleware resolves the active space for each request in this priority order:
- Subdomain —
ficto-imaging.example.com→ficto_imaging(hyphens become underscores; thepreview.prefix is stripped before resolution). - Query string —
?collection=ficto_imaging, which also persists to theficto_spacecookie. - Cookie —
ficto_spacefrom a prior selection. - Default — the first entry in
SiteOptions:Spaces.
Every content query is scoped to the active space's collection plus the shared "default" collection, so content that's intentionally cross-brand (e.g. the About us page) lives in one place but appears under every subsite.
All Delivery SDK access goes through IContentService (Services/Content/ContentService.cs). It:
- selects the preview or production named
IDeliveryClientbased onIPreviewContext.IsPreview, - applies the active-space +
"default"collection filter to every list and slug query, - returns
nullfor 404s and maps other failures to aContentDeliveryExceptionso controllers can stay terse.
URL resolution for content-item links (in navigation and rich text) is handled by IRouteResolver using the templates in SiteOptions:RouteTemplates:
| Content type | URL pattern |
|---|---|
page |
/{slug} |
article |
/articles/{slug} |
product |
/products/{slug} |
solution |
/solutions/{slug} |
Add a template if you introduce a new content type; anything not listed falls back to /{type}/{slug}.
Rich-text fields reach Razor as IRichTextContent on the view models and render via the Kontent.Ai.AspNetCore package's <rich-text content="@Model.Content" /> tag helper — see Views/Shared/_ContentChunk.cshtml for a minimal example. The tag helper resolves its HTML through whatever IHtmlResolver is registered in DI.
RichTextResolver (in Services/Content/) builds that single resolver. Inline linked items (Fact, Action, Callout) render through component-specific templates; links to other items resolve through IRouteResolver so <a href> values always match the routing table above. Custom anchor handling turns in-document references into deep-link #slug targets so table-of-contents links work.
Listing pages (Articles, Products) paginate through the SDK's Skip / Limit / WithTotalCount and return a PagedResult<T> so the view can render "Showing N–M of TOTAL" without a second count query. Products filter additionally by taxonomy — category codenames from the query string are passed into .Where(i => i.Element("category").ContainsAny(...)) against the product_category taxonomy group.
List queries also apply element projection via .WithElements(...) to trim the payload to just the fields the card needs. GetArticlesAsync drops the content rich-text body (the heaviest field) and GetProductsAsync drops the SEO metadata elements — the detail queries (*BySlugAsync) keep the full element set for the full-page view.
Asset-bearing view models expose IAsset? directly; mappers pass SDK values through without any intermediate projection. Views render them with the Kontent.Ai.AspNetCore package's <img-asset> tag helper, which emits srcset/sizes using the width ladder from ImageTransformationOptions:ResponsiveWidths in appsettings.json. The few CSS background-image sites that can't use a tag helper (hero slides in _VisualContainerHeroUnit.cshtml, the article/solution detail hero styles) build URLs directly via new ImageUrlBuilder(asset.Url).WithWidth(...).Url.
Kontent.ai's rendition presets let editors define image variants once in the environment, and the SDK applies them automatically. The sample uses a single SDK-level setting:
DeliveryOptions:DefaultRenditionPresetinappsettings.json(set to"default") — everyIAsset.Urlemitted by the SDK already contains the rendition transformation query, so the<img-asset>tag helper's generated URLs inherit the rendition crop for free.
The header menu is driven from a WebsiteRoot item in the active space. NavigationViewComponent fetches it via IContentService.GetNavigationAsync(), which uses GetItem<WebsiteRoot>(spaceCodename) with Depth(3) — enough to reach the top-level container, its nav items, and any dropdown subitems.
Preview mode switches the active IDeliveryClient to the preview-keyed instance so editors see unpublished drafts. A single query parameter — ?secret=<PreviewOptions:Secret> — is what flips it on. The sample ships with PreviewOptions:Secret = "mySecret" so everything works out-of-the-box; override it in user-secrets for anything reachable.
SpaceContextMiddleware runs on every request. If the request carries ?secret= and the value matches PreviewOptions:Secret (compared with CryptographicOperations.FixedTimeEquals), the middleware:
- Issues a signed
ficto_previewcookie viaIPreviewTokenProtector(HttpOnly, SameSite=None, Secure, 1-day expiry — required for cross-site iframe use from Kontent.ai). - 302-redirects to the same URL with
?secret=stripped, so the token never leaks into rendered HTML or the editor's URL bar.
On subsequent requests the valid cookie alone keeps IPreviewContext.IsPreview on, ContentService routes reads through the "preview" named Delivery client (from DeliveryOptions:PreviewApiKey), and the green banner shows at the top of every page. If the preview client isn't configured, the app logs a warning and silently serves production content — drafts just won't appear, no hard failure.
To exit preview, click the banner's Disable link (GET /preview/disable), which clears the cookie.
Point Kontent.ai at your local app so its live-preview iframe loads the rendered pages.
-
In Kontent.ai, open Environment Settings → Preview URLs.
-
On the Space domains tab, set the domain for every space (
ficto_imaging,ficto_healthtech,ficto_surgical) to:localhost:7108(Adjust the port if you've customised
applicationUrl— see Port overrides below.) -
Switch to the Preview URLs for content types tab and configure the template for each content type the app renders:
Content type Preview URL template website_roothttps://{Space}?collection={Collection}&secret=mySecretpagehttps://{Space}/{URLslug}?collection={Collection}&secret=mySecretarticlehttps://{Space}/articles/{URLslug}?collection={Collection}&secret=mySecretsolutionhttps://{Space}/solutions/{URLslug}?collection={Collection}&secret=mySecretproducthttps://{Space}/products/{URLslug}?collection={Collection}&secret=mySecret{Space},{Collection}, and{URLslug}are Kontent.ai macros — Kontent.ai expands them per item / collection at preview time. Thesecret=mySecretvalue must matchPreviewOptions:Secret; override it in user-secrets and update the templates accordingly before using a shared environment.
Once the iframe loads any of these URLs, the middleware sets the cookie, strips the secret from the URL, and subsequent clicks inside the iframe stay in preview mode via the SameSite=None; Secure cookie.
The :7108 / :5107 pair is just the default in Properties/launchSettings.json. Each URL in applicationUrl declares its own scheme explicitly — the port isn't bound to HTTP or HTTPS by position:
"applicationUrl": "https://localhost:7108;http://localhost:5107"Change either port freely (or swap in different ones). The ASP.NET Core dev cert is bound to the hostname localhost, not to a specific port, so HTTPS keeps working on whatever port you pick. Update the Space domains in Kontent.ai to match whichever HTTPS port you've configured — the iframe must load over HTTPS because Kontent.ai itself is served over HTTPS.
A shared URL secret is fine for a sample app — it is not a substitute for real authorization. Anyone who learns the secret sees drafts. For any reachable deployment, put a real auth boundary in front of preview requests using one of these idiomatic ASP.NET patterns:
-
Standard ASP.NET authentication middleware — configure
AddAuthentication/AddAuthorizationwith your IdP (OIDC, Entra ID, cookie auth, etc.) and short-circuit unauthenticated preview requests beforeSpaceContextMiddlewareruns. For example:app.Use(async (ctx, next) => { var entering = ctx.Request.Query.ContainsKey("secret"); var inPreview = ctx.Request.Cookies.ContainsKey(PreviewController.CookieName); if ((entering || inPreview) && !(ctx.User.Identity?.IsAuthenticated ?? false)) { await ctx.ChallengeAsync(); return; } await next(); });
-
Edge rules — Cloudflare Access, Azure Front Door rules, AWS Cognito, or plain HTTP basic auth at a reverse proxy can all gate preview requests before they ever hit the app. Works well when preview is exposed on a dedicated hostname (e.g.
preview.ficto.example.com).
Layer either approach on top of the ?secret= mechanism. The secret then serves as the "turn preview display on" toggle; the auth boundary decides who's allowed to flip it.
The ficto_preview cookie's value is opaque ciphertext protected by IDataProtectionProvider. Without signing, a visitor could type ficto_preview=enabled in devtools and bypass the secret check entirely; with signing, a forged value fails Unprotect and the middleware ignores it. The payload itself is a constant — the cookie says "this browser has presented a valid secret," nothing more.
The app integrates the Kontent.ai Smart Link SDK so editors in preview mode can click on one of the decorated elements and jump straight to editing the content in question.
- Script include —
_Layout.cshtmlrendersViews/Shared/_SmartLinkScript.cshtmlinside<head>whenIPreviewContext.IsPreviewis true, pullingkontent-smart-link@5from the jsDelivr CDN and callinginitializeOnLoad(). Production pages never load the SDK. - Environment + language attributes —
_Layout.cshtmlputsdata-kontent-environment-idanddata-kontent-language-codenameon<body>so the SDK can read them from any descendant. The environment ID comes fromDeliveryOptions:EnvironmentId; language is hard-coded todefault(the Ficto sample is single-language). - Item ID in view models — every view model that maps a content item exposes a
Guid? ItemIdproperty populated from the Delivery SDK'sIContentItem<T>.System.Id. Views emit it asdata-kontent-item-id="@Model.ItemId"; Razor's conditional-attribute rendering omits the attribute entirely whenItemIdisnull. - Element codenames in views — views decorate field-rendering tags with
data-kontent-element-codename="<codename>"using the element codenames from the generated models (Generated/Models/*.cs, e.g.product_base__name,title,reference__label). - Rich-text inline components —
RichTextResolveremitsdata-kontent-component-idon the root of each inline Fact / Action / Callout template so editors can click into components embedded inside a rich-text field. These attributes are harmless in production (the SDK never loads) so the resolver stays a pure singleton with no preview-state dependency.
Attribute hierarchy matches the SDK's contract:
<body data-kontent-environment-id="…" data-kontent-language-codename="default">
…
<section data-kontent-item-id="…">
<h1 data-kontent-element-codename="title">…</h1>
<img data-kontent-element-codename="main_image" … />
</section>
…
</body>
- Inside Kontent.ai live preview — when the app is loaded in Kontent.ai's preview iframe, the SDK auto-activates via iframe messaging. Nothing to do beyond a correctly configured preview URL (see Configuring the Kontent.ai preview URL).
- Standalone browser tab — after enabling preview, append
?ksl-enabledto any URL to activate overlays outside the iframe. Useful for debugging since browser devtools are fully accessible.
To add Smart Link support to a new content type:
- Add a
Guid? ItemId { get; init; }property to the view model. - Change the mapper's
TSourcefromT(bare elements) toIContentItem<T>(wrapper), read data viasource.Elements, and setItemId = source.System.Id. - Update call sites to pass the wrapper instead of
.Elements. - In the view, wrap the item's outer container with
data-kontent-item-id="@Model.ItemId"and decorate each field-rendering tag withdata-kontent-element-codename="<element codename>"(copy codenames fromGenerated/Models/<Type>.cs's[JsonPropertyName]attributes).
The app caches Delivery API responses via Kontent.Ai.Delivery.Caching (FusionCache backend) on the production client only — the preview client is deliberately uncached so editors see changes immediately. Every cached entry also has a time-based expiry that acts as a safety net in case a webhook is missed or not configured. The default is 60 seconds, controlled by SiteOptions:CacheExpirationSeconds in appsettings.json; raise it once webhooks are wired up to keep content fresh without re-fetching on every request.
The /webhooks/kontent endpoint receives Kontent.ai webhook notifications and invalidates the corresponding cache dependency keys for precise eviction on top of that time-based baseline. Signature validation happens upstream in UseWebhookSignatureValidator (from Kontent.Ai.AspNetCore), which verifies the X-Kontent-ai-Signature (and legacy X-KC-Signature) HMAC against WebhookOptions:Secret before the controller ever sees the request.
Kontent.ai dispatches webhooks from the public internet, so it can't POST directly to localhost. For local development, expose the app through a tunnel — ngrok, Cloudflare Tunnel, or equivalent — and use the tunnel's public HTTPS URL when registering the webhook:
ngrok http https://localhost:7108
# → forwarding https://<random>.ngrok-free.app → https://localhost:7108Then, in Kontent.ai under Environment settings → Webhooks, create a webhook pointing at https://<random>.ngrok-free.app/webhooks/kontent. Copy the signing key Kontent.ai generates for the webhook into WebhookOptions:Secret (via user-secrets) so signature validation succeeds. For deployed environments, point the webhook at your public domain directly — no tunnel needed.
The SDK does not traverse a dependency graph at invalidation time. Instead, every cached response is tagged at write time with a fan-out set of keys — for an item or item-list response that includes the response item codenames, every linked/modular-content item codename, every referenced asset id, every referenced taxonomy group codename, and the content type codename of every primary and modular-content item. Invalidating a single tag (e.g. item_homepage, type_article, taxonomy_personas) removes every cached entry that was tagged with it.
The synthetic listing-scope keys (scope_items_list, scope_types_list, scope_taxonomies_list) are the safety net for list-membership changes — events where a new item should now appear in a previously cached filter that was never tagged with the new item's codename.
The endpoint only acts on notifications with delivery_slot == "published". Preview events are skipped because the preview client is not cached.
object_type |
action |
Keys invalidated | Why |
|---|---|---|---|
content_item |
published |
item_<codename> + scope_items_list |
Could be first publish (membership shift) or republish — safe default. |
content_item |
unpublished |
item_<codename> |
Existing listings tagged with the codename are evicted; the item cannot newly appear in unrelated listings. |
content_item |
metadata_changed |
item_<codename> + scope_items_list |
Codename rename or collection move can shift filter membership. |
asset |
created |
(no-op) | The new asset isn't yet referenced by any cached item. |
asset |
changed / metadata_changed / deleted |
asset_<id> |
Items referencing the asset (asset element or rich-text inline image) carry the same tag. |
content_type |
created |
scope_types_list |
New type joins GetTypes() listings; no cached item could reference it yet. |
content_type |
changed / deleted |
type_<codename> + scope_types_list |
type_<codename> evicts the type definition and every cached item / item-list whose payload contains an item of that type (directly or via modular content / linked items / inline rich-text items). |
taxonomy |
created |
scope_taxonomies_list |
|
taxonomy |
metadata_changed / deleted |
taxonomy_<codename> + scope_taxonomies_list |
Items referencing terms in the group are tagged with the group codename and get evicted. |
taxonomy |
term_created / term_changed / term_deleted / terms_moved |
taxonomy_<group_codename> (from data.system.taxonomy_group) |
Same fan-out as above — every item using a term in this group is tagged with the group codename. |
language |
created / changed / deleted |
Full purge via IDeliveryCachePurger.PurgeAsync() |
No language-scope key exists; languages affect every variant of every cached entry. |
Unknown object_type values are ignored and logged at Debug. Any notification processed in a webhook batch can opt into the full purge — if a single language event is present in the payload, the entire request is handled as a purge.
See Webhooks reference for the canonical payload structure. Codenames are read from notifications[].data.system.codename; asset ids from notifications[].data.system.id; taxonomy term events read the parent group from notifications[].data.system.taxonomy_group.
For Contributing please see CONTRIBUTING.md for more information.
Distributed under the MIT License. See LICENSE.md for more information.