The Problem
If you have ever built a site on Sitecore AI, you have probably seen image URLs that look like this:
https://edge.sitecorecloud.io/yourcompany1-yourcompanyltd-p49d-fb1a/media/images/HeroBanner.png
That works fine while you are prototyping or testing. But when real customers visit your site, a URL that says "sitecorecloud.io" can cause real problems:
- Your images are not showing up in Google Image search. Sitecore adds an
X-Robots-Tag: noindexheader to every media request served from the default Edge domain. Search engines see that and skip your assets. - It looks unprofessional. Brand teams hate sharing links that point to someone else's domain.
- Analytics and cookies get messy. Browsers are blocking third-party cookies, and a "shared" domain makes it harder to track your own traffic correctly.
- You lose domain authority. Search engines don't credit your brand for content hosted on a shared, multi-tenant domain.
In this post I will explain the idea behind custom media URLs in Sitecore AI, and then walk through a real, production-hardened pattern step by step: a tiny front-end hook that swaps the Edge URL for your own branded media URL. This is based on a pattern in active use on a production Sitecore AI site (a large retail/e-commerce company), with a few small additions to make it safe in the editor, easy to switch off, and simple to configure per environment.
next.config.ts.First, the concept: what does a media URL actually contain?
Every image in a Sitecore media library gets published to Experience Edge. When the Layout Service hands your front-end the image URL, it gives you an absolute URL that includes:
- Hostname:
edge.sitecorecloud.io(or your organisation-specificxmc-...sitecorecloud.iohost) - Tenant path: something like
yourcompany1-yourcompanyltd-p49d-fb1a— this identifies your project - Media path:
/media/... - Query parameters: image resizing hints like
?h=318&w=528
You cannot simply swap edge.sitecorecloud.io for media.yourcompany.com. Why? Because of one small but important detail. Take a look at this.
The magic path: /v1/media/edge/images
Sitecore lets you register your own custom hostname (also called a custom domain) against your organisation. You do this once in the Sitecore Cloud Portal under Admin → Custom hostnames, and then you point DNS records at it. The docs explain it well — the official page is Resolving a custom hostname during a request in the Cloud Portal documentation.
Here is the important part, straight from the Sitecore docs:
"To prevent search engines from indexing your media on the edge.sitecorecloud.io domain, Experience Edge media requests include the X-Robots-Tag: noindex header. However, when requesting a media item from a custom hostname, you can use the relative path /v1/media/edge/images/ to have the X-Robots-Tag: noindex header omitted from the response."
So a correctly formed custom media URL looks like this:
| Type | URL |
|---|---|
| Default (no good for SEO) | https://edge.sitecorecloud.io/org-tenant/media/images/Hero.png?h=318&w=528 |
| Custom domain, with the magic path | https://media.yourcompany.com/v1/media/edge/images/org-tenant/media/images/Hero.png?h=318&w=528 |
Notice what changed: the hostname was replaced and the sequence /v1/media/edge/images was inserted right after the hostname. Everything after that — the tenant path, media path, and resize parameters — stays exactly the same. That is the whole trick!
Two approaches to build these URLs
There are two places you can build the custom URL:
| Approach | Where the rewrite happens | Pros / Cons |
|---|---|---|
| Platform level (custom hostname + DNS + Cloud Portal) | Sitecore / Edge CDN | All clients benefit automatically; no code changes. But most of your URL still gets generated by the front-end SDK, so you usually end up doing the rewrite in code anyway. |
| Front-end level (a URL-swap function) | Your Next.js app, just before rendering | Simple, fast, easy to toggle per environment, and this is where the Layout Service URLs live anyway. Needs a small code change and a whitelist config. |
Most teams — including ours — end up using both: the custom hostname is registered for SEO and DNS, and the front-end does the actual URL swap. This post focuses on the front-end part, because that is where the reusable, interesting code lives.
Let's build it
Our plan: write one small hook that-
(1) reads an ImageField from the Layout Service,
(2) checks a few safety conditions, and
(3) returns a new field whose src points at our media domain.
Then call it from whichever components render images.
These examples use the @sitecore-content-sdk/nextjs package in a Next.js app with the App Router, but the idea ports to any framework.
Step 1: Add your environment variables
Step 1
We need two settings, one per environment (local, staging, production). Because the rewrite happens in client components, the variable names must start with NEXT_PUBLIC_ — that is how Next.js makes a variable available in the browser bundle. This is a classic gotcha: if you forget the prefix, the code happily runs, the values are undefined, and the rewrite silently does nothing.
# .env.local (and equivalents for staging / production)
# The default Experience Edge origin (what the Layout Service gives us)
NEXT_PUBLIC_SITECORE_EDGE_URL=https://edge.sitecorecloud.io
# Our own media host, including the magic path
NEXT_PUBLIC_SITECORE_MEDIA_URL=https://media.yourcompany.com/v1/media/edge/images
NEXT_PUBLIC_* variable into the browser bundle at build time. Using a non-public name means the browser sees undefined and no rewrite happens — with no error to guide you.Step 2: Write the media URL hook
Step 2
This is the heart of it. The hook below is a cleaned-up version of a production implementation. Save it somewhere reusable — in our project that is src/components/shared/hook/use-custom-media-url.ts, so any feature component can share it.
'use client'
import { ImageField, LayoutServicePageState, useSitecore } from '@sitecore-content-sdk/nextjs'
import { useAppContext } from '@/components/shared' // your app context, for the feature toggle
type SetCustomMediaUrl = (field: ImageField) => ImageField
export function useCustomMediaUrl(): { setCustomMediaUrl: SetCustomMediaUrl } {
const { featureToggles } = useAppContext()
const { page } = useSitecore()
const { pageState } = page.layout.sitecore.context
const isEnabled = !!featureToggles?.customMediaUrl
const setCustomMediaUrl: SetCustomMediaUrl = (field) => {
// 1. Feature toggle off? Then never touch the URL.
if (!isEnabled) return field
// 2. No src, or we are in the Experience Editor / Preview? Leave it alone.
if (!field?.value?.src || pageState !== LayoutServicePageState.Normal) return field
// 3. A check that helps local development: skip rewriting on localhost.
const isLocal = process.env.NEXT_PUBLIC_SITE_DOMAIN?.includes('localhost')
if (isLocal) return field
const EDGE_URL = process.env.NEXT_PUBLIC_SITECORE_EDGE_URL
const MEDIA_URL = process.env.NEXT_PUBLIC_SITECORE_MEDIA_URL
// 4. Environment not configured? Don't rewrite, just return the original field.
if (!EDGE_URL || !MEDIA_URL) return field
// 5. Only rewrite URLs that really come from Edge.
if (!field.value.src.startsWith(EDGE_URL)) return field
// 6. Rewrite (just a string swap!) and return a NEW field — we never mutate the input.
return {
...field,
value: {
...field.value,
src: field.value.src.replace(EDGE_URL, MEDIA_URL),
},
}
}
return { setCustomMediaUrl }
}
There is only one line of real logic here — the replace() — and six guards around it. Let me walk you through why each guard matters:
- Feature toggle (guard 1): This is the on/off switch. If media is misbehaving in production, you can switch the toggle off in the CMS with zero code changes — a safety net that has saved us more than once.
- Editor check (guard 2): During content editing (Experience Editor, Preview), the URLs are handled by Sitecore's own media services. Rewriting them there would break the editor experience, so we skip it. This is why you check
LayoutServicePageState.Normal. - Localhost check (guard 3): On a developer machine you usually can't reach the media CDN, so keep the original Edge URL. Just a small quality-of-life guard.
- Env check (guard 4): If the env vars aren't configured (say a fresh checkout), fall back gracefully instead of producing broken URLs.
- Prefix check (guard 5): Only swap URLs that actually come from Experience Edge. If some images are already pointing at your media domain (or a CDN), leave them alone.
- Immutability (guard 6): We return a brand-new field object. React and Sitecore's SDK expect fields to be treated as immutable.
key, enable), one per site/market. A server-side GraphQL query reads these, and the value travels down to the client through your app context. If that plumbing feels like too much for your project, you can replace featureToggles?.customMediaUrl with a plain boolean env var — the rest of the hook stays identical.Step 3: Use the hook in a component
Step 3
Now take your "Media" component (the one that renders images from a Sitecore datasource) and apply the rewrite:
'use client'
import type { ImageField } from '@sitecore-content-sdk/nextjs'
import { NextImage, Text } from '@sitecore-content-sdk/nextjs'
import { useCustomMediaUrl } from '@/components/shared/use-custom-media-url'
interface Fields { Image?: ImageField; Caption?: { value?: string } }
export const ImageComponent = ({ fields }: { fields: Fields }) => {
const { setCustomMediaUrl } = useCustomMediaUrl()
const image = fields?.Image
const rewrittenImage = image ? setCustomMediaUrl(image) : undefined
if (!rewrittenImage) return null
return <NextImage field={rewrittenImage} />
}
That's it — from this point on, every image rendered by this component goes to media.yourcompany.com on production while staying untouched in the editor and on localhost.
In our real codebase this hook is called from more than a dozen places: the generic image component, videos (both the src and the poster), cards, image scrollers, and background images. The pattern is always the same — setCustomMediaUrl(field) right before rendering.
<video>, run the rewrite on both the video source and the poster image. And if you get URLs out of a rich-text field's HTML, a regex over src/href attributes does the same job (the Fishtank article shows a neat example of that).Step 4: Tell next/image which domains are allowed
Step 4
Next.js's next/image optimizer refuses to fetch images from domains it does not know about. If you don't do this step, you get a fresh error the first time the rewrite kicks in:
Invalid src prop ... hostname is not configured under images in your next.config.js
Add the media domain (and the Edge origin, for those pages that still need it, like the editor) to next.config.ts:
images: {
remotePatterns: [
// Experience Edge default host — used while editing and for any non-rewritten URLs
{ protocol: 'https', hostname: 'edge*.**' },
// Your own media host(s)
{ protocol: 'https', hostname: 'media.yourcompany.com' },
{ protocol: 'https', hostname: '*.yourcompany.com' },
],
},
Note the glob patterns: edge*.** covers the default host, and *.yourcompany.com covers any subdomain of your brand domain. If you run multiple markets (say .com.au and .co.nz), list each one.
Step 5: Wire up the feature toggle (the safe on/off switch)
Step 5
Last but not least, the safety net. In the CMS, create a small item per site under a folder like /Settings/Feature Toggle/, using a simple template with two fields:
| Field | Value | Meaning |
|---|---|---|
key | customMediaUrl | Turning this item into a flag the front-end can read |
enable | true | The switch itself |
On the server, one small GraphQL query reads these flags and caches them (we cache for 10 minutes using Next.js's data cache):
query GetFeatureToggles {
item(path: "/sitecore/content/YOURSITE/Settings/Feature Toggle", language: "en") {
children {
... on FeatureToggle {
key { value }
enable { boolValue }
}
}
}
}
The result travels down through your React context provider, and the hook reads it on every render. On any given environment you flip the CMS item to enable = false and the whole feature switches off instantly — no deploy, no rollback, no code change.
So what changed? A quick before/after
| Before | After | |
|---|---|---|
| Image URL | https://edge.sitecorecloud.io/org-tenant/media/images/Hero.png?h=318&w=528 | https://media.yourcompany.com/v1/media/edge/images/org-tenant/media/images/Hero.png?h=318&w=528 |
| Search engines | Ignored (X-Robots-Tag: noindex) | Indexed |
| Branding | Someone else's domain | Yours |
| In the editor? | — | Still original Edge URL (we skip rewriting) |
Troubleshooting
Here are the problems we have actually hit in production, and what fixed them:
| Symptom | Likely cause / fix |
|---|---|
Images still show edge.sitecorecloud.io in the browser | The env vars are not NEXT_PUBLIC_ prefixed, so the browser sees undefined. Or the feature toggle is off in that environment. |
hostname is not configured under images error | Missing images.remotePatterns entry in next.config.ts. |
| Image broken in Experience Editor | Working as designed — we intentionally don't rewrite in edit mode. Leave the toggle on but confirm pageState logic. |
Media still carries noindex on the custom host | Check the URL really contains /v1/media/edge/images right after the hostname, verify DNS/SSL on the custom hostname, and remember custom hostnames only work with the live context ID and media published to Experience Edge. |
| SSL validation on the custom hostname keeps timing out | A common cause is a CAA record restricting who can issue certificates. The Fishtank article documents fixing it by allowing pki.goog (or the relevant CA) in your CAA record. |
Wrap-up: when should you do this?
Short answer: as soon as you know what your media domain will be. The Fishtank article recommends setting it up early rather than retrofitting — the domain can take hours to be ready, and hoping to remember every component that renders an image is exactly how images end up forgotten. Making custom domains part of your go-live checklist is sensible advice.
Here is the checklist we use:
- Register the custom media hostname in the Sitecore Cloud Portal (Admin → Custom hostnames) and complete DNS + SSL validation.
- Add
NEXT_PUBLIC_SITECORE_EDGE_URLandNEXT_PUBLIC_SITECORE_MEDIA_URL(with the/v1/media/edge/imagespath) to every environment. - Create the
useCustomMediaUrlhook with its guards (toggle, editor, localhost, env, prefix, immutability). - Call
setCustomMediaUrlin every image/video/card component before rendering. - Add the media domains to
images.remotePatternsinnext.config.ts. - Create the feature-toggle item in the CMS so the feature can be switched off without a deploy.
- In the Network tab, confirm the final
srcuses your domain and the response has noX-Robots-Tag: noindexheader.
The whole feature is a single string replacement guarded by sensible checks. It took about a day to implement properly — including the feature toggle and the remote-pattern configuration — and it removed a whole class of SEO, branding, and analytics headaches before the site ever hit production.
Happy building! If you found this useful, share it with a teammate who is still sending media through edge.sitecorecloud.io.
Comments
Post a Comment