Render content from a .NET server
Ebitex.Content.Delivery is a server-side client for the Content Delivery API. It resolves a page by
its address, and reads components, streams, navigation and sitemap data, returning each as a typed
.NET value. Rendering stays yours: pass what it returns to Razor views, Blazor components or any other
templates.
The package targets .NET 10 and depends only on Microsoft.Extensions.Http and
Microsoft.Extensions.Options. It works the same in ASP.NET Core, a worker service or a console app.
Every C# method this page names is in one file, shown in full at the end of the page under The complete example.
Before you start
Section titled “Before you start”You need a site published in Content, a server-side delivery key, and your site’s id.
If your organization is new and nothing is published yet, there is nothing for this package to
resolve, and every address answers path_not_found.
Set up your organization covers signing up, importing a whole sample
content model, and publishing it.
Create a server-side delivery key
Section titled “Create a server-side delivery key”- In Content, open Settings > API Keys.
- Under Delivery keys, enter a name for the key and leave Browser-safe key off.
- If your content is published to more than one delivery environment, choose the one this key reads.
- Select Create key, and copy the key. It is shown only once.
A server-side key works from anywhere, so keep it on your server and out of source control. In development, user secrets work well:
dotnet user-secrets set "Content:DeliveryKey" "<your delivery key>"A browser-safe key cannot be used here. It only answers requests that carry one of its allowed
origins, and a server sends no Origin header, so every request is refused with
403 origin_denied.
Find your site’s id
Section titled “Find your site’s id”Every request names its site by id. The API is reached at its own hostname, so it cannot work out your site from the request. List the sites your key can read:
curl https://api.ebitex.io/content/delivery/v1/sites \ -H "Authorization: Bearer <your delivery key>"[ { "rootNodeId": "6f1c2e4a-8b1d-4c3e-9f2a-1d7e5b3c9a40", "name": "Northwind", "hosts": ["northwind.example"], "deliveryEnvironmentId": "9c41a2f0-1b3e-4d2a-8f7c-5e6d4b3a2c10" }]rootNodeId is your site’s id. Keep deliveryEnvironmentId too: it is optional, and
Create the client explains what it does. DescribeSitesAsync in the example
makes the same call through the client. See List sites for the full
response.
Install
Section titled “Install”dotnet add package Ebitex.Content.DeliveryCreate the client
Section titled “Create the client”AddEbitexContentDelivery registers ContentDeliveryClient as a typed client through
IHttpClientFactory, which pools connections and refreshes DNS correctly in a long-running server.
Inject ContentDeliveryClient wherever you need it. The method returns an IHttpClientBuilder, so
you can add handlers or resilience policies to it as usual. See Configure in the example.
Without dependency injection, construct the client yourself with an HttpClient you own, and reuse
it for the life of the process. See Create in the example.
| Option | Default | Meaning |
|---|---|---|
ApiKey |
Required | Your server-side delivery key. Checked when the client is created. |
SiteId |
None | Your site’s id, sent with every site-scoped request. Needed in practice: without it the API looks for a site at its own hostname and finds none. |
BaseUrl |
https://api.ebitex.io |
The API’s origin. The client adds the /content/delivery/v1 path itself, so leave it out. |
DeliveryEnvironmentId |
None | The key’s delivery environment. When set, the sites, locales and sitemap responses can be served from the shared edge cache. |
DefaultResolve |
10 |
How many levels of references each request expands when a call does not say. 10 is the maximum, and expands every reference in one request, which is what a server-rendered page wants. |
Resolve a page
Section titled “Resolve a page”ResolvePathAsync takes the request path exactly as your site received it and returns a
PathResult. It is always one of four types:
| Result | Meaning | Answer with |
|---|---|---|
PathResult.Presentation |
A page is published at this path. | A rendered page. |
PathResult.Redirect |
The path redirects, for example because the page was renamed. TargetPath is the new address, already in your site’s URL space. |
A permanent redirect (308) to TargetPath. |
PathResult.NotFound |
Nothing is published at this path. | Your 404 page. |
PathResult.Unknown |
A kind of result this version of the package does not recognize. Raw holds the response. |
An error, and a package update. |
RenderAsync in the example maps each one to a status code, independent of any web framework. In
ASP.NET Core, route a catch-all pattern such as {**path} to it, return a view for a page, and
return RedirectPermanentPreserveMethod or Results.Redirect with permanent and
preserveMethod set for a redirect.
A PathResult.Presentation carries what a page’s <head> needs:
Title: the page’s title.Path: its canonical address. Use it for your canonical link.Locale: the resolved locale on a site whose addresses carry one, for<html lang>.Envelope: the page’s Presentation.Envelope.Template.ExternalIdnames the Template, so choose your view by it.ViewNameForin the example does this, and the same rule applies to every Presentation nested inside the page.
See Resolve a path for the underlying request.
Sites with several locales
Section titled “Sites with several locales”Send the path exactly as your site received it. The client never adds a locale to it or removes one.
| How the site addresses locales | What to send |
|---|---|
| One address per page, whatever the locale | The path, and PathOptions.Locale if you serve more than one locale. |
A locale prefix in the path, such as /fr/cafes |
The path, prefix included. The locale is read from it. |
| A hostname per locale | The path, and PathOptions.Host set to the hostname you are serving. The host identifies both the site and the locale. |
Sending PathOptions.Locale to a site whose addresses carry the locale answers
400 locale_not_addressable, and the exception’s message says what to send instead. Every address
the API returns (Path, TargetPath, a link’s Url, a reference’s Paths) is already in your
site’s URL space, so use it as delivered.
Errors
Section titled “Errors”A missing page is a result, not an exception. Every other failure, including a 404 from any other
endpoint, throws ContentDeliveryException, which carries:
StatusCodeandErrorCode, theerrormember of the response, such asnot_published.ResponseBody, the raw response, for any other members an error carries.RetryAfter, on a429, when the server says how long to wait.RequestId, the request’s reference. Quote it if you contact support.
The exception’s message ends with the request reference and, for errors whose fix is not obvious,
says what to change. SiteHeaderAsync in the example treats an unpublished component as absent and
lets everything else through.
Read the content
Section titled “Read the content”A page’s content is page.Envelope.Component.Content: a JsonElement whose properties are the
Contract’s fields, keyed by each field’s id. The shape of your own fields is yours, so they stay
JSON. The values ebitex defines have typed readers, as extension methods on JsonElement:
| Field type | Read it with | Returns |
|---|---|---|
| Presentation | AsPresentation() |
PresentationEnvelope: Template, Settings and the bound Component. |
| Component or reference | AsComponent() |
ComponentValue |
| Link | AsLink() |
LinkValue. Url is null while the link’s target is unpublished. |
| Blob | AsBlob() |
BlobValue. Url is public, so it can go straight into an img tag. |
| Category | AsCategory() |
CategoryDescriptor: Path, Value (the display label) and GroupExternalId. |
| RichText | AsRichText() |
An ordered list of RichTextFragment |
A ComponentValue is either a reference to another component (IsReference is true) or a value
authored inline. IsResolved says whether its Content is in this response; at the default depth
it always is, unless the referenced component can no longer be found (Unresolvable). Summarize in
the example reads one field of each type.
To bind a whole document to your own type, pass source-generated metadata to ContentAs, which is
safe under trimming and native AOT. ReadCoffee in the example does this, and a reflection-based
overload exists too.
RichText
Section titled “RichText”A RichText value is a list of fragments in reading order. Match on the fragment’s type:
RichTextFragment.Markdown: a run of markdown inText. Render each as its own document.RichTextFragment.Presentation: an embedded Presentation. Render itsEnvelopewith the view its Template names.RichTextFragment.Reference: an embedded reference, an older embed kind still delivered where it was authored.RichTextFragment.Unknown: a fragment kind newer than this package. Skip it, or render a placeholder.
RenderRichText in the example renders a value this way.
Markdown fragments use exactly CommonMark plus the GitHub-flavored pipe tables and strikethrough
extensions. Raw HTML is refused when content is authored, so disable it in your renderer too. With
Markdig, build the pipeline with UsePipeTables, UseEmphasisExtras(EmphasisExtraOptions.Strikethrough)
and DisableHtml. Avoid UseAdvancedExtensions, which also renders autolinks, task lists and
footnotes that authors were never warned about. The package does not render markdown itself.
Personalize with ctx
Section titled “Personalize with ctx”ContentContext is the personalization context sent as ctx with every request that accepts one.
Set whatever your Audiences test:
Set(key, value)for a string, boolean, number or list of strings.Audiences(...)for the audiences the visitor belongs to.
The client always sends a context, an empty one included. Sending one makes the server choose every
personalized value before responding, so a response never contains undecided variants and your code
needs no rules of its own. The context is part of the API’s cache key, so one client serving many
visitors never mixes their content. RenderAsync in the example sends one.
Build a sitemap
Section titled “Build a sitemap”GetSitemapAsync fetches the published pages, and SitemapBuilder.BuildDocuments turns them into
sitemap XML. SitemapAsync in the example builds them, and SitemapXmlAsync answers a request for
one.
- If the site fits within the sitemap protocol’s limits of 50,000 URLs and 50 MB, you get one
document at
/sitemap.xml. Otherwise you get a sitemap index at/sitemap.xmlplus shards (/sitemap-1.xml,/sitemap-2.xml, and so on). Serve each document at exactly itsPathwith the content typeapplication/xml, because the index names those addresses. Originis required: your site’s canonical origin, such ashttps://northwind.example. Set it from configuration, never from the incoming request.LocaleUrladdshreflangalternates. It receives a path, a locale and the page, and returns that page’s URL in that locale. Returnnullwhen the page has no version in that locale: Content does not record which pages are translated, so only you know. On a site where the locale is part of the address, the path you are given is already correct, so return it unchanged.Excludetakes exact paths, such as/search, and whole sections, such as/account/*. For anything else, useFilter.
SitemapBuilder.BuildXml builds a single document, and throws SitemapTooLargeException rather
than producing an invalid one when the site is past either limit. See
Get the sitemap for the data behind it.
Caching
Section titled “Caching”The client caches nothing, on purpose. A server runs for weeks, and a cache without an expiry keeps serving its first answer long after the content changed. The API caches its own responses and clears them when you publish, so the next request after a publish gets the new content.
If you add a cache of your own, for example IMemoryCache:
- Give every entry an expiry you have chosen, and accept that published changes appear only once it passes.
- Key each entry by everything that changes the answer: the path, the locale or host, and the whole context.
- Never cache a response read with a live-preview session.
Live preview
Section titled “Live preview”A site that renders on its server can show Content’s live preview too. PreviewAsync in the example
handles the preview session. It needs a delivery key created with Allow draft preview on, and
your site’s host set to Server in the Live preview section of the site’s settings, under
Configure > Sites.
The complete example
Section titled “The complete example”The file below also lists components, reads a stream facet, and builds a breadcrumb from the navigation tree. Complete sites built on this API, with the content model each expects, are in the ebitex samples repository.
// Examples for the Ebitex.Content.Delivery package. Each method is one task from the .NET guide.// The file is compiled against the published package, so every call in it is public API.
using System.Text;using System.Text.Json.Serialization;using Ebitex.Content.Delivery;using Ebitex.Content.Delivery.Sitemap;using Microsoft.Extensions.DependencyInjection;
namespace Northwind.Web;
public static class DocsExamples{ // Register the client once, at startup. Read the key from configuration or a secret store, // never from source control. Inject ContentDeliveryClient wherever you need it. public static IServiceCollection Configure(IServiceCollection services, string deliveryKey, Guid siteId) { services.AddEbitexContentDelivery(options => { options.ApiKey = deliveryKey; options.SiteId = siteId; }); return services; }
// Without dependency injection, pass an HttpClient you own, and reuse the resulting client for // the life of the process rather than creating one per request. public static ContentDeliveryClient Create(HttpClient http, string deliveryKey, Guid siteId) => new(http, new ContentDeliveryOptions { ApiKey = deliveryKey, SiteId = siteId });
// Lists the sites the key can read. A site's RootNodeId is the value SiteId takes. public static async Task<IReadOnlyList<string>> DescribeSitesAsync(ContentDeliveryClient delivery, CancellationToken cancellationToken) { var sites = await delivery.ListSitesAsync(cancellationToken); return sites.Select(site => $"{site.Name}: {site.RootNodeId} ({string.Join(", ", site.Hosts)})").ToList(); }
// What a page request should answer with, independent of your web framework. public sealed record PageResponse(int StatusCode, string? Location = null, PathResult.Presentation? Page = null);
// Resolves whatever is published at the path your site received. public static async Task<PageResponse> RenderAsync(ContentDeliveryClient delivery, string requestPath, string buyerType, CancellationToken cancellationToken) { var result = await delivery.ResolvePathAsync(requestPath, new PathOptions { // Sent as ctx: whatever your Audiences test, so personalized values arrive decided. Context = new ContentContext().Set("buyerType", buyerType), // Attaches each referenced page's own address, so you can link to it. IncludeReferencePaths = true, }, cancellationToken);
return result switch { // A page. Render page.Envelope with the view its Template names. PathResult.Presentation page => new PageResponse(200, Page: page), // A redirect, such as the old address of a renamed page. PathResult.Redirect redirect => new PageResponse(308, Location: redirect.TargetPath), // Nothing is published at this path. PathResult.NotFound => new PageResponse(404), // A result kind this version of the package does not know. _ => new PageResponse(500), }; }
// Chooses a view by Template, which works the same for the page and every Presentation in it. public static string ViewNameFor(PresentationEnvelope envelope) => envelope.Template?.ExternalId switch { "page" => "Page", "coffee-detail" => "CoffeeDetail", _ => "Unsupported", };
// Field values are JsonElement, keyed by each Contract field's id. The As… helpers read the // values ebitex defines: Presentations, components, links, blobs, categories and RichText. public static string Summarize(PathResult.Presentation page) { if (page.Envelope?.Component?.Content is not { } content) { return ""; }
var heading = content.GetProperty("heading").GetString(); var hero = content.GetProperty("hero").AsPresentation(); var origin = content.GetProperty("origin").AsComponent(); var cta = content.GetProperty("cta").AsLink(); // Url is null while the target is unpublished var photo = content.GetProperty("photo").AsBlob(); // Url is public: no key is needed to load it var roast = content.GetProperty("roast").AsCategory(); var body = content.GetProperty("body").AsRichText();
return $"{heading} | {ViewNameFor(hero)} | {(origin.IsResolved ? "origin loaded" : "origin not loaded")} | {cta.Url} | {photo.Url} | {roast.Path} | {body.Count} fragments"; }
// Or bind a whole document to your own type. Source-generated metadata is trim- and AOT-safe. public static Coffee? ReadCoffee(ComponentValue component) => component.ContentAs(CoffeeJsonContext.Default.Coffee);
// A RichText value is an ordered list of fragments. Render each Markdown fragment as its own // markdown document, and each embedded Presentation with the view its Template names. public static string RenderRichText( IReadOnlyList<RichTextFragment> body, Func<string, string> markdownToHtml, Func<PresentationEnvelope, string> renderEmbed) { var html = new StringBuilder(); foreach (var fragment in body) { switch (fragment) { case RichTextFragment.Markdown markdown: html.Append(markdownToHtml(markdown.Text)); break; case RichTextFragment.Presentation embed: html.Append(renderEmbed(embed.Envelope)); break; default: // Reference (an older embed kind) or Unknown (a kind newer than this package): // skip it or render a placeholder. break; } }
return html.ToString(); }
public static IReadOnlyList<string> MarkdownOf(ComponentValue component) => component.Content is { } content ? content.GetProperty("body").AsRichText().OfType<RichTextFragment.Markdown>().Select(fragment => fragment.Text).ToList() : [];
// A missing page is a PathResult. Every other failure throws ContentDeliveryException. public static async Task<ComponentValue?> SiteHeaderAsync(ContentDeliveryClient delivery, CancellationToken cancellationToken) { try { // A component's Guid or its external id. return await delivery.GetComponentAsync("site-header", cancellationToken: cancellationToken); } catch (ContentDeliveryException failure) when (failure.ErrorCode == "not_published") { return null; } catch (ContentDeliveryException failure) { // The message names the status, the error code and, for some codes, the fix. Quote // RequestId if you contact support. On a 429, wait for RetryAfter before retrying. Console.Error.WriteLine($"{failure.Message} (status {failure.StatusCode}, retry after {failure.RetryAfter})"); throw; } }
// Lists components of one Contract, newest first. public static async Task<IReadOnlyList<string?>> NewestPostTitlesAsync(ContentDeliveryClient delivery, CancellationToken cancellationToken) { var page = await delivery.ListComponentsAsync(new ComponentQuery { Contract = "blog-page", OrderBy = "publish-date", Descending = true, Limit = 10, }, cancellationToken); return page.Items.Select(item => item.Title).ToList(); }
// Counts one value of a stream's facet. Pass a facet the same filters you pass the stream. public static async Task<int> LightRoastCountAsync(ContentDeliveryClient delivery, CancellationToken cancellationToken) { var facet = await delivery.GetStreamFacetAsync("coffees", "roast", new StreamFacetOptions(), cancellationToken); return facet.Values.Where(value => value.Label == "Light").Sum(value => value.Count); }
// A breadcrumb: the page's ancestors and the page itself, root first. public static async Task<IReadOnlyList<string>> BreadcrumbAsync(ContentDeliveryClient delivery, string path, CancellationToken cancellationToken) { var tree = await delivery.ListNodesAsync(new NodesOptions { From = path, Include = NodeInclude.Ancestors | NodeInclude.Self }, cancellationToken); return tree.Nodes.Select(node => node.Title).ToList(); }
// Builds the sitemap: one document, or an index plus shards when the site is too large for one. public static async Task<IReadOnlyList<SitemapDocument>> SitemapAsync(ContentDeliveryClient delivery, CancellationToken cancellationToken) { var data = await delivery.GetSitemapAsync(cancellationToken: cancellationToken); return SitemapBuilder.BuildDocuments(data, new SitemapDocumentsOptions { // Your site's canonical origin. Never derive it from the incoming request. Origin = "https://northwind.example", // Emits hreflang alternates. Return null for a locale this page has no version in. LocaleUrl = (path, _, _) => path, // Exact paths, or a whole subtree with /*. Exclude = ["/search", "/account/*"], }); }
// Answers /sitemap.xml and every shard it names. Serve the result as application/xml, and // answer 404 when this returns null. public static async Task<string?> SitemapXmlAsync(ContentDeliveryClient delivery, string requestPath, CancellationToken cancellationToken) { var documents = await SitemapAsync(delivery, cancellationToken); return documents.FirstOrDefault(document => document.Path == requestPath)?.Xml; }
// Live preview for a site that renders on its server: the token in the framed URL is spent once // for a session secret, kept in your own cookie; later reads send it; an ended session clears // the cookie and renders the published page. Needs a key with draft preview allowed. public static async Task<(string? SessionToStore, bool ClearCookie, PathResult Result)> PreviewAsync( ContentDeliveryClient delivery, string path, string? tokenFromQuery, string? sessionFromCookie, CancellationToken cancellationToken) { if (tokenFromQuery is not null) { // Spend the token once. A real site stores grant.Session in an HttpOnly, Secure, // SameSite=None, Partitioned cookie and redirects to the same URL without the token. var grant = await delivery.ExchangePreviewTokenAsync(tokenFromQuery, cancellationToken); var draft = await delivery.ResolvePathAsync(path, new PathOptions { PreviewSession = grant.Session }, cancellationToken); return (grant.Session, false, draft); }
try { return (null, false, await delivery.ResolvePathAsync(path, new PathOptions { PreviewSession = sessionFromCookie }, cancellationToken)); } catch (ContentDeliveryException failure) when (failure.IsPreviewSessionEnded) { return (null, true, await delivery.ResolvePathAsync(path, cancellationToken: cancellationToken)); } }}
// Your own type for one Contract. Property names are the Contract's field ids.public sealed record Coffee( [property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("tasting-notes")] string? TastingNotes, [property: JsonPropertyName("price")] decimal? Price);
[JsonSerializable(typeof(Coffee))]internal sealed partial class CoffeeJsonContext : JsonSerializerContext{}