Skip to content

05 — Media and Blob Storage

Dev Guide — Savoy Signature Hotels
PRD refs: 13_Media_and_Image_Pipeline.md, 02_Infrastructure_and_Environments.md


Media assets are uploaded to Umbraco and stored in Azure Blob Storage. The frontend never processes images server-side — all image transformation is handled by Cloudflare Images at the edge. This guide covers Blob Storage setup, media library organization, upload restrictions, image optimization, and the delivery pipeline.


Editor uploads image to Umbraco

Azure Blob Storage (original file)

https://{account}.blob.core.windows.net/media/{id}/{filename}

Umbraco Content API returns JSON with original URL

Next.js custom loader translates to Cloudflare Images URL

Cloudflare pulls from Blob, transforms

(resize, WebP/AVIF), caches

Browser receives optimized image

Key principle: No server-side image processing. Umbraco.ImageSharp is NOT used for frontend delivery. All processing happens at the Cloudflare edge.


<PackageReference Include="Umbraco.StorageProviders.AzureBlob" Version="..." />
{
"Umbraco": {
"Storage": {
"AzureBlob": {
"Media": {
"ConnectionString": "${AZURE_BLOB_CONNECTION_STRING}",
"ContainerName": "media"
}
}
}
}
}
OperationMethod
Read (public)Container is public for reads (Cloudflare fronts)
WriteSAS tokens or Azure Managed Identity

Benefit: Stateless web app — Umbraco can scale horizontally or teardown without losing media files.


Pre-create this folder structure BEFORE editors start uploading:

Media Library
├── Savoy Signature/
│ ├── Homepage/
│ │ ├── Desktop/
│ │ └── Mobile/
│ ├── Rooms/
│ │ ├── Desktop/
│ │ └── Mobile/
│ ├── Restaurants/
│ ├── Spa/
│ ├── Events/
│ └── General/
├── Savoy Palace/
│ ├── Homepage/
│ │ ├── Desktop/
│ │ └── Mobile/
│ ├── Rooms/
│ ├── Restaurants/
│ └── ...
├── Royal Savoy/
│ └── ...
├── Saccharum/
│ └── ...
├── The Reserve/
│ └── ...
├── Calheta Beach/
│ └── ...
├── Gardens/
│ └── ...
├── Hotel Next/
│ └── ...
└── _Shared/ # Underscore prefix sorts first
├── Logos/
├── Icons/
├── Legal/
└── Group/

Rules:

  • One folder per hotel — editors should NOT cross-reference between hotel folders
  • Desktop/Mobile sub-folders for image-heavy sections (Hero, Rooms, Galleries)
  • _Shared folder (underscore prefix) for common assets across all sites
  • File naming convention: {site}-{section}-{subject}-{variant}.{ext}

File Naming Examples:

savoy-palace-room-deluxe-ocean-desktop.jpg
savoy-palace-room-deluxe-ocean-mobile.jpg
royal-savoy-hero-main-desktop.jpg
royal-savoy-hero-main-mobile.jpg
_shared-logo-group-color.svg

Configure in Umbraco backoffice (Settings > Media Types):

File TypeMax SizeAllowed Extensions
ImagesDesktop: 1MB, Mobile: 500KB.jpg, .jpeg, .png, .webp, .svg
Videos10MB.mp4, .webm
Documents5MB.pdf, .docx
  • Short looping mute background videos (< 10MB): Upload to Blob Storage
  • Full videos with audio (> 10MB): MUST be hosted on YouTube/Vimeo and embedded via videoBlock (M12) module
  • Never upload large video files to Blob Storage

SVGs uploaded to Media Library are sanitized on upload:

  • Strip ALL &lt;script&gt; tags
  • Strip ALL on* event attributes (onclick, onload, etc.)
  • This prevents XSS via malicious SVG files

SVG usage split:

  • Theme UI icons (arrows, close, menu, social): Inline in Next.js packages/ui as React components
  • Hotel logos and content illustrations: Uploaded to Media Library as <img src=".svg">

Every image in the system requires both Desktop and Mobile variants.

ContextDesktop DimensionsMobile Dimensions
Hero / Full-width1920x1080750x1000
Card thumbnail600x400375x250
Gallery1440x960750x500
Logo / IconsSVGSVG
VariantMax File Size (compressed)
Desktop images500KB
Mobile images200KB

Every module that displays images uses the responsiveImageComposition or responsiveImageItem:

imageDesktop (Media Picker, required, 1920x1080+)
imageMobile (Media Picker, required, 750x1000+)

For galleries, use the responsiveImageItem Element Type:

PropertyAliasEditorRequired
Image DesktopimageDesktopMedia PickerYes
Image MobileimageMobileMedia PickerYes
Alt TextaltTextTextstringYes
CaptioncaptionTextstringNo

Umbraco’s Image Cropper editor is used ONLY for focal point definition (not predefined crops).

  1. Editor uploads image and sets focal point in Umbraco Image Cropper
  2. API returns focal point coordinates: focalPoint: { top: 0.3, left: 0.5 }
  3. Frontend translates to CSS: object-position: 50% 30%
  4. No hardcoded crops — uses &lt;picture&gt; + object-fit: cover
function focalPointToObjectPosition(focalPoint: { top: number; left: number }): string {
return `${Math.round(focalPoint.left * 100)}% ${Math.round(focalPoint.top * 100)}%`;
}
// Usage in component:
// style={{ objectPosition: focalPointToObjectPosition(image.focalPoint) }}

The Next.js custom image loader routes all images through Cloudflare for on-the-fly transformation:

apps/web/src/lib/cloudflare-image-loader.ts
export default function cloudflareLoader({
src,
width,
quality,
}: {
src: string;
width: number;
quality?: number;
}) {
const params = [
`width=${width}`,
`quality=${quality || 75}`,
'format=auto', // Cloudflare picks WebP or AVIF based on browser support
].join(',');
return `https://savoysignature.com/cdn-cgi/image/${params}/${src}`;
}

next.config.ts:

const nextConfig = {
images: {
loader: 'custom',
loaderFile: './src/lib/cloudflare-image-loader.ts',
},
};
SettingValue
PolishLossy
WebPEnabled
MirageEnabled (lazy-loads images on slow connections)

  • PT is the default — all images are uploaded for PT
  • EN override is uploaded ONLY when the image contains visible text (menus, infographics, certificates)
  • Images without text content serve the PT version for both languages
export function resolveMediaForLocale(
mediaProperty: any,
locale: string
): UmbracoMedia | null {
// Invariant media (no culture variants)
if ('url' in mediaProperty) return mediaProperty;
// Check for locale-specific variant
if (locale === 'en' && mediaProperty.en) return mediaProperty.en;
// Fallback to PT
return mediaProperty.pt || null;
}

Form file uploads (CVs on Careers page) use a separate Blob container:

SettingValue
Containerform-uploads
Max file size5MB
Allowed types.pdf, .docx
AccessPrivate (no public read)
DownloadSecure link via CMS auth or expiring SAS token
RetentionAuto-delete after 90 days (GDPR)

Rule: File attachments are NEVER sent directly in emails. Emails contain secure download links only.


AssetBudget
Total page weight< 500KB gzipped
LCP image (desktop)< 500KB
LCP image (mobile)< 200KB
Fonts< 100KB per theme
Third-party scripts< 100KB

12. Step-by-Step: Setting Up Blob Storage for a New Environment

Section titled “12. Step-by-Step: Setting Up Blob Storage for a New Environment”
  1. Create Azure Storage Account — Standard LRS, Hot tier
  2. Create media container — Set public access level to “Blob” (read-only)
  3. Create form-uploads container — Set access level to “Private”
  4. Install NuGet packageUmbraco.StorageProviders.AzureBlob
  5. Configure connection string — Store in Azure Key Vault, inject via Managed Identity
  6. Configure Cloudflare — Point image CDN URLs to Blob Storage origin
  7. Enable Polish — Lossy compression + WebP in Cloudflare dashboard
  8. Pre-create Media Library folders — Follow the structure in Section 4
  9. Test upload — Verify images are accessible via Cloudflare CDN URL

PitfallSolution
Missing mobile image variantEvery image requires imageDesktop + imageMobile
Large video uploaded to BlobVideos > 10MB must be on YouTube/Vimeo
Images exceeding size limitsDesktop max 1MB, Mobile max 500KB (before Cloudflare compression)
Cross-site media referencesKeep media isolated per hotel folder
Unsanitized SVG uploadEnsure SVG sanitization strips &lt;script&gt; and on* attributes
Using Umbraco ImageSharp for frontendAll image processing is via Cloudflare Images, not server-side
Forgot focal pointAlways use Image Cropper editor for focal point, even if default center