AngryMonkey.CloudBlazor.Web
1.0.1
dotnet add package AngryMonkey.CloudBlazor.Web --version 1.0.1
NuGet\Install-Package AngryMonkey.CloudBlazor.Web -Version 1.0.1
<PackageReference Include="AngryMonkey.CloudBlazor.Web" Version="1.0.1" />
<PackageVersion Include="AngryMonkey.CloudBlazor.Web" Version="1.0.1" />
<PackageReference Include="AngryMonkey.CloudBlazor.Web" />
paket add AngryMonkey.CloudBlazor.Web --version 1.0.1
#r "nuget: AngryMonkey.CloudBlazor.Web, 1.0.1"
#:package AngryMonkey.CloudBlazor.Web@1.0.1
#addin nuget:?package=AngryMonkey.CloudBlazor.Web&version=1.0.1
#tool nuget:?package=AngryMonkey.CloudBlazor.Web&version=1.0.1
CloudBlazor.Web
Server-side website infrastructure for Blazor and MVC: page head metadata, SEO and robots directives, asset bundles, and crawler detection.
Formerly published as AngryMonkey.CloudWeb and AngryMonkey.CloudWeb.Server. See
Migrating.
Features
- Fluent per-page metadata: title, description, icons, theme colour, web app manifest
- Multiple favicon formats, Apple touch icons, and performance resource hints
- Route metadata is reset automatically during interactive navigation
- Application-wide defaults through
CloudWebConfig, overridden per page - Title prefix and suffix, title add-ons, and automatic 64-character limiting
- Description truncation at 160 characters
- Canonical URLs and
hreflanglanguage alternates, derived automatically on multilingual sites - Open Graph and Twitter cards, falling back to the page's own title and description
- JSON-LD structured data, escaped so it cannot break out of its
<script>element - Robots directives (
noindex,nofollow,max-image-preview,max-snippet,noarchive) with automatic preview-host protection sitemap.xmlandrobots.txtendpoints generated from code- CSS and JavaScript bundles with minified path insertion and cache-busting versions
- CDN feature flags and a legacy
exportsshim - Crawler detection from an extensive user-agent list
- Works in Blazor and MVC, and initializes CloudBlazor in either
Installation
dotnet add package AngryMonkey.CloudBlazor.Web
Quick start
1. Register
using AngryMonkey.CloudBlazor;
using AngryMonkey.CloudBlazor.Web;
builder.Services.AddCloudWeb(config =>
{
config.TitleSuffix = " - My Application";
config.PageDefaults
.SetTitle("Home")
.SetDescription("Default site description.")
.SetKeywords("cloudblazor, aspnetcore, seo")
.SetFavicon("/favicon.svg")
.SetThemeColor("#0B5FFF")
.SetManifest("/site.webmanifest");
config.PageDefaults.AddHeadLinks(
CloudHeadLink.Icon("/favicon-96x96.png", sizes: "96x96"),
CloudHeadLink.AppleTouchIcon("/apple-touch-icon.png", "180x180"),
CloudHeadLink.FontPreload("/fonts/site.woff2"));
});
2. Blazor
App.razor — mark where the managed head renders:
<head>
<CloudHeadPlaceholder />
</head>
Routes.razor — render the managed head content, outside the router so it survives navigation:
<CloudHeadContent />
<Router AppAssembly="typeof(Program).Assembly">
...
</Router>
Use
<CloudHeadPlaceholder />rather than writing<SectionOutlet SectionName="CloudWeb" />by hand. There is noSectionPlaceholdercomponent in Blazor — earlier documentation named one, and because a mistyped component name compiles to an inert HTML element, the head silently rendered nothing.CloudWebSections.Headexposes the raw value if you need it.
Per page:
@inject CloudPage CloudPage
@code {
protected override void OnInitialized() =>
CloudPage
.SetTitle("Dashboard")
.SetDescription("Operational dashboard")
.SetKeywords("dashboard, analytics");
}
3. MVC
Derive from CloudController and call CloudPage() in each action:
public class HomeController(CloudPage cloudPage) : CloudController(cloudPage)
{
public IActionResult Index()
{
CloudPage("Home").SetDescription("Home page");
return View();
}
}
_Layout.cshtml:
<head>
<component type="typeof(CloudHeadInit)" render-mode="Static" />
</head>
Add @using AngryMonkey.CloudBlazor.Web to _ViewImports.cshtml to use @Html.Bundle(...)
in views.
Page metadata
All setters return this and raise OnModified, which re-renders the head.
@inject CloudPage CloudPage
@code {
protected override void OnInitialized() =>
CloudPage
.SetTitle("Contact")
.SetDescription("Get in touch with us.")
.SetKeywords("contact, support")
.SetFavicon("/icons/contact.svg")
.SetThemeColor("#0B5FFF")
.SetManifest("/site.webmanifest");
}
| Method | Description |
|---|---|
SetTitle(string) |
Sets the title. Prefix and suffix are applied automatically. |
SetDescription(string) |
Sets the meta description. Truncated past 160 characters. |
SetKeywords(string) |
Sets the meta keywords tag. |
SetFavicon(string) |
Sets the favicon href and infers its MIME type. |
SetThemeColor(string) |
Sets the browser UI theme colour. |
SetManifest(string) |
Sets the web app manifest href. |
SetTitleAddOns(IEnumerable<string>) |
Appends title tokens within the 64-character limit. |
AddHeadLink(CloudHeadLink) |
Adds an icon, preload, preconnect, or other reusable head link. |
AddHeadLinks(params CloudHeadLink[]) |
Adds several head links in render order. |
Reset() |
Clears route metadata while retaining request safety settings. Interactive navigation calls it automatically. |
Icons and performance links
SetFavicon() remains the concise single-icon API. Use CloudHeadLink when a site needs the
complete icon set expected by browsers and search results, or when a critical resource should
be discovered early:
config.PageDefaults.AddHeadLinks(
CloudHeadLink.Icon("/favicon.svg"),
CloudHeadLink.Icon("/favicon-96x96.png", sizes: "96x96"),
CloudHeadLink.AppleTouchIcon("/apple-touch-icon.png", "180x180"),
CloudHeadLink.FontPreload("/fonts/site.woff2"));
The descriptor and its CloudHeadLinks renderer live in the common AngryMonkey.CloudBlazor
package, so the same API works in WebAssembly and hybrid hosts. For localized assets, use
CloudHeadLink.LocalizedFontPreload(...); it selects the full UI culture first, then the neutral
language, without loading every language's font. MIME types are inferred from the resolved URL
for icons, images, fonts, stylesheets, scripts, and manifests. Set Type only when an unusual
extension or server response requires an explicit override.
CloudHeadContent also clears page-specific title, canonical, robots, alternates, social data,
JSON-LD, bundles, and head links before an interactive route renders. Consuming pages no longer
need a custom cleanup helper to prevent metadata from the previous route leaking into the next.
Title
// Config: TitleSuffix = " - My App"
CloudPage.SetTitle("About");
// Renders: <title>About - My App</title>
With no per-page title, PageDefaults.Title is used without prefix or suffix.
Title add-ons are appended while the combined title stays within 64 characters; tokens that would overflow are dropped rather than truncated:
CloudPage.SetTitleAddOns(["Page 3", "Category A"]);
Defaults
Per-page values override defaults through null-coalescing — an explicit page value wins,
otherwise the default from PageDefaults applies.
Canonical is the one exception: it is never inherited from PageDefaults. A default canonical
would point every page at the same URL, which is the single fastest way to de-index a site.
Canonical URLs and language alternates
CloudPage
.SetCanonical("/about")
.AddAlternate("en", "/about")
.AddAlternate("ar", "/ar/about")
.AddAlternate(CloudAlternateLink.XDefault, "/about");
<link rel="canonical" href="https://example.com/about" />
<link rel="alternate" hreflang="en" href="https://example.com/about" />
<link rel="alternate" hreflang="ar" href="https://example.com/ar/about" />
<link rel="alternate" hreflang="x-default" href="https://example.com/about" />
A canonical URL tells a search engine which address is authoritative when the same content
answers on more than one URL. Language alternates stop a set of translations being read as
duplicate content; x-default marks the fallback for unmatched locales.
Adding the same hreflang twice replaces it rather than emitting two contradictory tags.
Absolute URLs
Relative values are resolved against CloudWebConfig.BaseUrl, falling back to the current
request's scheme and host. Configure BaseUrl when the public address differs from what the
application sees — behind a proxy or CDN, or when only one of several hosts is canonical.
builder.Services.AddCloudWeb(config => config.BaseUrl = "https://example.com");
Multilingual sites
Describe the languages once and the canonical link, the hreflang set and og:locale are
derived from the request path for every page:
builder.Services.AddCloudWeb(config =>
{
config.BaseUrl = "https://example.com";
config.Localization = new CloudLocalizationOptions
{
DefaultCulture = "en",
SupportedCultures = ["en", "ar"],
Locales = new Dictionary<string, string> { ["en"] = "en_US", ["ar"] = "ar_AR" },
};
});
The convention is the usual one: the default language at /path, every other language at
/{culture}/path. On /ar/about that produces:
<link rel="canonical" href="https://example.com/ar/about" />
<link rel="alternate" hreflang="en" href="https://example.com/about" />
<link rel="alternate" hreflang="ar" href="https://example.com/ar/about" />
<link rel="alternate" hreflang="x-default" href="https://example.com/about" />
<meta property="og:locale" content="ar_AR" />
<meta property="og:locale:alternate" content="en_US" />
A page that sets its own canonical or alternates keeps them; derivation only fills gaps. A page
marked SetIndexPage(false) gets neither — an error page stands in for many URLs at once, so
naming one of them as canonical would be wrong.
| Property | Default | Description |
|---|---|---|
DefaultCulture |
required | Served from unprefixed URLs, and advertised as x-default. |
SupportedCultures |
required | Every language, including the default. |
Locales |
{} |
Open Graph locale per culture. Unmapped cultures use the culture name with - replaced by _. |
AutoCanonical |
true |
Derive the canonical URL. |
AutoAlternates |
true |
Derive the hreflang set. |
AutoLocale |
true |
Derive og:locale and its alternates. |
The same options build sitemap entries, so one description of the site's languages covers both:
app.MapCloudSitemap(sitemap =>
{
foreach (string path in new[] { "", "about", "contact" })
sitemap.AddLocalized([.. localization.AlternatesFor(path)], xDefault: "en");
});
Open Graph and Twitter cards
What a link looks like when it is pasted into a chat or a social post.
CloudPage
.SetSiteName("My Application")
.SetOpenGraphType("article")
.SetLocale("en_US")
.AddLocaleAlternates("ar_AR")
.SetImage(new CloudPageImage
{
Url = "/img/og.png",
Width = 1200,
Height = 630,
Alt = "Product screenshot",
})
.SetTwitterSite("@myapp");
| Method | Description |
|---|---|
SetSiteName(string) |
Site name shown alongside the title. |
SetOpenGraphType(string) |
Object type. Defaults to website. |
SetSocialTitle(string) |
Overrides the preview title. |
SetSocialDescription(string) |
Overrides the preview description. |
SetImage(string) / SetImage(CloudPageImage) |
Preview image, shared by both formats. |
SetLocale(string) / AddLocaleAlternates(params string[]) |
This page's locale and the others it exists in. |
SetTwitterCard(CloudTwitterCards) |
Card layout. |
SetTwitterSite(string) / SetTwitterCreator(string) |
Site and author handles. |
Three defaults keep most pages to a single line of configuration:
- Title and description fall back to the page's own metadata, so only pages that need different wording set them.
- The card layout is
summary_large_imageonce an image is set, andsummarywhen not. - The image MIME type is inferred from the file extension.
Tags are emitted only once a page has something to preview, so a page that sets none of this
carries no social markup. Setting SetSiteName in PageDefaults opts the whole site in.
The social title uses the raw
SetTitlevalue rather than the composed one. A site-wide title suffix reads as noise in a shared link, where the site name already appears separately asog:site_name.
Structured data
JSON-LD describes what a page is, which is what makes rich results possible.
CloudPage.AddStructuredData(new Dictionary<string, object>
{
["@context"] = "https://schema.org",
["@type"] = "Organization",
["name"] = "My Company",
["url"] = "https://example.com",
});
// Or JSON you already have
CloudPage.AddStructuredData(jsonString);
Each call adds a document; every one renders as its own application/ld+json script. Anonymous
types, dictionaries and POCOs all work, which keeps @context and @type expressible without a
schema.org type library. Null properties are dropped from serialized objects.
Non-ASCII text is left readable rather than escaped, so Arabic or Chinese content does not triple in size.
Escaping.
<is written as<in the rendered script. In well-formed JSON that character only occurs inside a string literal, where the escape is equivalent — but it means a value containing</script>cannot close the element and inject markup. Documents supplied as raw JSON are escaped the same way.
Sitemap and robots.txt
Both are mapped as endpoints, so they are generated from code instead of being static files in
wwwroot that drift out of date.
app.MapCloudSitemap(sitemap => sitemap
.Add("/", changeFrequency: CloudChangeFrequencies.Weekly, priority: 1.0)
.Add("/about", lastModified: DateTimeOffset.UtcNow)
.Add("/contact"));
app.MapCloudRobotsTxt();
MapCloudRobotsTxt() with no arguments allows everything and advertises /sitemap.xml. Both
accept a route pattern if you need a different path.
Localized sitemaps
AddLocalized writes one URL per language and cross-links every variant from each of them.
Search engines require that set to be complete and reciprocal:
sitemap.AddLocalized(
[
new CloudAlternateLink("en", "/about"),
new CloudAlternateLink("ar", "/ar/about"),
], xDefault: "en");
Dynamic entries
The delegate overload runs per request with access to the request scope, for sitemaps built from a database:
app.MapCloudSitemap(async (context, sitemap) =>
{
ArticleService articles = context.RequestServices.GetRequiredService<ArticleService>();
foreach (Article article in await articles.GetPublishedAsync())
sitemap.Add($"/articles/{article.Slug}", article.UpdatedAt);
});
robots.txt rules
app.MapCloudRobotsTxt(robots => robots
.Allow("/")
.Disallow("/admin")
.Disallow("/internal", "Googlebot")
.CrawlDelay(1)
.AddSitemap("/sitemap.xml"));
Rules are grouped per user agent, and a repeated user agent extends its existing group. A
request to a non-production host is served Disallow: / instead, matching the protection the
robots meta tag already applies to preview deployments.
robots.txtgoverns crawling; the robots meta tag governs indexing. A URL disallowed here can still be indexed from an external link, so useSetIndexPage(false)to keep a page out of an index.
Asset bundles
Global bundles from PageDefaults render before per-page bundles, so a page can override a
site-wide rule without touching configuration.
// By path
CloudPage.AppendBundle("css/theme.css");
CloudPage.AppendBundles("css/a.css", "js/b.js");
// With options
CloudPage.AppendBundle(new CloudBundle
{
Source = "js/analytics.js",
MinOnRelease = true,
AppendVersion = true,
Defer = true,
Async = false,
UseMapping = true,
AddOns = null,
});
// At a specific position — critical CSS first
CloudPage.InsertBundle(0, new CloudBundle { Source = "css/critical.css" });
| Property | Default | Description |
|---|---|---|
Source |
required | Relative path, or an absolute http(s) URL. |
MinOnRelease |
true |
Inserts .min. before the extension outside Development. |
AppendVersion |
true |
Appends a content-based version for cache busting. |
UseMapping |
true |
Resolves through the static asset manifest; falls back to IFileVersionProvider. |
Defer |
true |
Adds defer to <script> tags. |
Async |
false |
Adds async to <script> tags. |
AddOns |
null |
Attribute string appended verbatim to the tag. |
Only .css and .js sources render; anything else is ignored rather than emitted as a broken
tag.
CloudBundleis a plain model. It used to double as the component that rendered it, which raisedBL0005in every application that configured a bundle from code.CloudBundleTagrenders it now.
CDN features
CloudPage.AddFeature(CloudPageFeatures.JQuery);
| Feature | Injected dependency |
|---|---|
CloudPageFeatures.JQuery |
jQuery 3.6.4 from code.jquery.com, with subresource integrity |
Feature dependencies render before page bundles, since a dependency has to load before the code that uses it.
Legacy exports shim
Older CommonJS bundles expect a global exports object:
CloudPage.SetAddLegacyExportsCreation(true);
Robots control
CloudPage.SetIndexPage(false); // noindex
CloudPage.SetFollowPage(false); // nofollow
IndexPage |
FollowPage |
Output |
|---|---|---|
true |
true |
(no tag emitted) |
false |
true |
<meta name="robots" content="noindex"> |
true |
false |
<meta name="robots" content="nofollow"> |
false |
false |
<meta name="robots" content="noindex, nofollow"> |
Nothing is emitted when both are allowed: the absence of a robots tag already means index and follow.
Preview and snippet directives
Beyond indexing, a page can state how much of itself a search result may show:
CloudPage
.SetMaxImagePreview(CloudMaxImagePreviews.Large)
.SetMaxSnippet(-1) // -1 lifts the limit, 0 suppresses snippets
.SetMaxVideoPreview(-1) // seconds
.SetNoArchive(true); // no cached copy
<meta name="robots" content="noarchive, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
max-image-preview:large is what makes a page eligible for a large thumbnail, and for Google
Discover. These usually belong in PageDefaults rather than on individual pages.
They are dropped when the page is noindex: an excluded page has no preview to size, so
emitting both would be contradictory.
Preview host protection
A request whose host ends in a suffix from CloudWebConfig.NonProductionHostSuffixes
(azurewebsites.net by default) is served noindex, nofollow regardless of per-page settings,
so a staging deployment cannot be indexed by accident. Blazor and MVC share the same check:
bool isPreview = CloudWebConfig.IsNonProductionHost(host);
Crawler detection
@inject CloudPage CloudPage
@if (CloudPage.IsCrawler)
{
<ServerRenderedSummary />
}
else
{
<InteractiveExperience />
}
MVC controllers use the helper on CloudController:
public IActionResult Index() => IsCrawler() ? View("IndexSimple") : View();
The check is also available directly:
bool isCrawler = CloudWebConfig.IsCrawler(userAgent);
The user agent is lower-cased and matched against a lower-cased, de-duplicated substring list
covering search engines (googlebot, bingbot, baiduspider), generic patterns (bot,
crawler, spider), command-line tools (wget, curl) and SEO crawlers (ahrefsbot,
blexbot).
Configuration reference
| Property | Type | Description |
|---|---|---|
TitlePrefix |
string |
Prepended to a per-page title. |
TitleSuffix |
string |
Appended to a per-page title. |
BaseUrl |
string? |
Canonical origin used to make canonical URLs, alternates, preview images and sitemap locations absolute. Falls back to the request's own origin. |
Localization |
CloudLocalizationOptions? |
Language-to-URL mapping. When set, canonical, hreflang and og:locale are derived per request. |
StaticFilesBaseDirectory |
string? |
Base directory stripped and restored when resolving asset paths through IFileVersionProvider. |
PageDefaults |
CloudPage |
Default metadata, bundles, features and robots settings. Canonical is never inherited. |
IncludeCloudBlazorScript |
bool (true) |
Emits the CloudBlazor browser-behavior module into the managed head. |
Initializing CloudBlazor
IncludeCloudBlazorScript is enabled by default so a CloudWeb site initializes CloudBlazor in
every hosting model, including MVC and static pages that never load a Blazor script and
therefore have no JS initializer pipeline. Initialization is idempotent, so this is safe
alongside the initializer. Turn it off when the host does not need it:
builder.Services.AddCloudWeb(config => config.IncludeCloudBlazorScript = false);
Migrating from AngryMonkey.CloudWeb
| Previous package | Last version | Replacement |
|---|---|---|
AngryMonkey.CloudWeb |
2.3.0 | AngryMonkey.CloudBlazor.Web |
AngryMonkey.CloudWeb.Server |
2.4.3 | AngryMonkey.CloudBlazor.Web |
The previous packages are no longer updated. The repositories were merged into CloudBlazor, where all four packages ship as a matched set.
Required changes
| Before | After |
|---|---|
using AngryMonkey.CloudWeb; |
using AngryMonkey.CloudBlazor.Web; |
@using AngryMonkey.CloudWeb |
@using AngryMonkey.CloudBlazor.Web |
<SectionPlaceholder SectionName="CloudWeb" /> |
<CloudHeadPlaceholder /> |
@Html.Bundle(...) with no import |
Add @using AngryMonkey.CloudBlazor.Web to _ViewImports.cshtml |
CloudPageExtension.Current(viewData) |
CloudPageExtensions.Current(viewData) |
Unchanged: AddCloudWeb, CloudPage, CloudWebConfig, CloudBundle, CloudPageFeatures,
CloudController, CloudHeadContent, CloudHeadInit, and every method on CloudPage.
Two behaviours changed as bug fixes rather than API changes:
- Crawler matching now works for most of the list. Entries were authored with mixed casing
(
Baiduspider,AhrefsBot) but compared against a lower-cased user agent, so they could never match. Expect more requests to be identified as crawlers than before. CloudBundleis no longer a component, which removesBL0005warnings from application code. Constructing and configuring it is unchanged; rendering<CloudBundle ... />directly in markup is not supported.
License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- AngryMonkey.CloudBlazor (>= 1.0.1)
NuGet packages (3)
Showing the top 3 NuGet packages that depend on AngryMonkey.CloudBlazor.Web:
| Package | Downloads |
|---|---|
|
AngryMonkey.CDM.Server
Package Description |
|
|
AngryMonkey.CloudLogin.Server
Package Description |
|
|
AngryMonkey.CloudLogin.Web
Add this library to the main stand alone Login website. |
GitHub repositories
This package is not used by any popular GitHub repositories.