Skip to main content

Custom Media URLs in Sitecore AI: A Simple Guide to Serving Media From Your Own Domain

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: noindex header 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.

What you will learn
A simple concept (a URL swap at the front-end) plus the production details that make it safe: avoiding the Experience Editor, an on/off switch managed in the CMS, environment variables, and a small update to 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-specific xmc-...sitecorecloud.io host)
  • 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:

TypeURL
Default (no good for SEO)https://edge.sitecorecloud.io/org-tenant/media/images/Hero.png?h=318&w=528
Custom domain, with the magic pathhttps://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!

Do I need to register a custom hostname at the portal level for this to work?
For full production value — indexable media on your own domain, cookie/analytics improvement — yes, register the hostname in the Cloud Portal and wire up DNS. The front-end rewrite below assumes clients can reach that hostname. If you are not ready to touch DNS yet, the same code still works pointing at the default Edge host — you just keep the old domain.

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
Configuring the variable name starts with NEXT_PUBLIC_
Because these variables are read in a client component, Next.js inlines any 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.
About the feature toggle
A feature toggle is just a small Sitecore item with two fields (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.

Don't forget videos and posters
If you render HTML5 <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:

FieldValueMeaning
keycustomMediaUrlTurning this item into a flag the front-end can read
enabletrueThe 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

BeforeAfter
Image URLhttps://edge.sitecorecloud.io/org-tenant/media/images/Hero.png?h=318&w=528https://media.yourcompany.com/v1/media/edge/images/org-tenant/media/images/Hero.png?h=318&w=528
Search enginesIgnored (X-Robots-Tag: noindex)Indexed
BrandingSomeone else's domainYours
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:

SymptomLikely cause / fix
Images still show edge.sitecorecloud.io in the browserThe 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 errorMissing images.remotePatterns entry in next.config.ts.
Image broken in Experience EditorWorking 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 hostCheck 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 outA 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_URL and NEXT_PUBLIC_SITECORE_MEDIA_URL (with the /v1/media/edge/images path) to every environment.
  • Create the useCustomMediaUrl hook with its guards (toggle, editor, localhost, env, prefix, immutability).
  • Call setCustomMediaUrl in every image/video/card component before rendering.
  • Add the media domains to images.remotePatterns in next.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 src uses your domain and the response has no X-Robots-Tag: noindex header.

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

POPULAR POSTS

Sitecore PowerShell Script to create all language versions for an item from en version

  We have lots of media items and our business wants to copy the data from en version of media item to all other language versions defined in System/Languages. This ensures that media is available in all the languages. So, we created the below powershell script to achieve the same -  #Get all language versions defined in System/Languages $languages = Get-ChildItem /sitecore/System/Languages -recurse | Select $_.name | Where-Object {$_.name -ne "en"} | Select Name #Ensuring correct items are updated by comparing the template ID  $items = Get-ChildItem -Path "/sitecore/media library/MyProjects" -Recurse | Where-Object {'<media item template id>' -contains $_.TemplateID} #Bulk update context to improve performance New-UsingBlock (New-Object Sitecore.Data.BulkUpdateContext) { foreach($item in $items){    foreach($language in $languages){ $languageVersion = Get-Item -Path $item.Paths.Path -Language $language.Name #Check if language versi...

Export Sitecore media library files to zip using SPE

If you ever require to export Sitecore media files to zip (may be to optimize them), SPE (Sitecore Powershell Extension) has probably the easiest way to do this for you. It's as easy as the below 3 steps -  1. Right click on your folder (icons folder in snap)>Click on Scripts> Click on Download 2. SPE will start zipping all the media files placed within this folder. 3. Once zipping is done, you will see the Download option in the next screen. Click Download Zip containing the media files within is available on your local machine. You can play around with the images now. Hope this helps!! Like and Share ;)

Make Sitecore instance faster using Roslyn Compiler

When we install the Sitecore instance on local, the first load is slow. After each code deploy also, it takes a while for the Sitecore instance to load and experience editor to come up. For us, the load time for Sitecore instance on local machines was around 4 minutes. We started looking for ways to minimize it and found that if we update our Web.config to use Roslyn compiler and include the relevant Nugets into the project, our load times will improve. We followed the simple steps - Go to the Project you wish to add the NuGet package and right click the project and click 'Manage NuGet Packages'. Make sure your 'Package Source' is set to nuget.org and go to the 'Browse' Tab and search Microsoft.CodeDom.Providers.DotNetCompilerPlatform. Install whichever version you desire, make sure you note which version you installed. You can learn more about it  here . After installation, deploy your project, make sure the Microsoft.CodeDom.Providers.DotNetCompilerPlatform.d...

Experience of a first time Sitecore MVP

The Journey I have been working in Sitecore for almost 10 years now. When I was a beginner in Sitecore, I was highly impressed by the incredible community support. In fact, my initial Sitecore learning path was entirely based on community written blogs on Sitecore. During a discussion with my then technology lead Neeraj Gulia , he proposed the idea that I should start giving back to developer community whenever I get chance. Just like I have been helped by many developers via online blogs, stackoverflow etc., I should also try to help others. Fast forward a few years and I met  Nehemiah Jeyakumar  (now an MVP). He had a big archive of his technical notes in the form Sitecore blogs. I realized my first blog dont have to be perfect and it can be as simple as notes to a specific problem for reference in future. That's when I probably created my first blog post on Sitecore. At that time, I didn't knew about the Sitecore MVP program. Over the years, I gained more confidence to writ...

Clean Coding Principles in CSharp

A code shall be easy to read and understand. In this post, I am outlining basic principles  about clean coding after researching through expert recommended books, trainings and based on my experience. A common example to start with is a variable declaration like - int i  The above statement did not clarify the purpose of variable i. However,  the same variable can be declared as -  int pageNumber The moment we declared the variable as int pageNumber, our brain realized that the variable is going to store the value for number of pages. We have set the context in our brain now and it is ready to understand what the code is going to do next with these page numbers. This is one of the basic advantages of clean coding. Reasons for clean coding -  • Reading clean code is easier - Every code is revisited after certain amount of time either by the same or different developer who created it. In both the cases, if the code is unclean, its difficult to understand and u...