Skip to content

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.

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.

  1. In Content, open Settings > API Keys.
  2. Under Delivery keys, enter a name for the key and leave Browser-safe key off.
  3. If your content is published to more than one delivery environment, choose the one this key reads.
  4. 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:

Terminal window
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.

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:

Terminal window
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.

Terminal window
dotnet add package Ebitex.Content.Delivery

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.

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.ExternalId names the Template, so choose your view by it. ViewNameFor in the example does this, and the same rule applies to every Presentation nested inside the page.

See Resolve a path for the underlying request.

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.

A missing page is a result, not an exception. Every other failure, including a 404 from any other endpoint, throws ContentDeliveryException, which carries:

  • StatusCode and ErrorCode, the error member of the response, such as not_published.
  • ResponseBody, the raw response, for any other members an error carries.
  • RetryAfter, on a 429, 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.

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.

A RichText value is a list of fragments in reading order. Match on the fragment’s type:

  • RichTextFragment.Markdown: a run of markdown in Text. Render each as its own document.
  • RichTextFragment.Presentation: an embedded Presentation. Render its Envelope with 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.

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.

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.xml plus shards (/sitemap-1.xml, /sitemap-2.xml, and so on). Serve each document at exactly its Path with the content type application/xml, because the index names those addresses.
  • Origin is required: your site’s canonical origin, such as https://northwind.example. Set it from configuration, never from the incoming request.
  • LocaleUrl adds hreflang alternates. It receives a path, a locale and the page, and returns that page’s URL in that locale. Return null when 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.
  • Exclude takes exact paths, such as /search, and whole sections, such as /account/*. For anything else, use Filter.

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.

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.

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

DocsExamples.cs
// 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
{
}