Asset CDN Proxy
How tenant-uploaded media flows from the CMS to your tenant app — and why every published tenant app needs a /cdn/[...path] route.
The CMS stores uploaded files (images, video, documents) in S3-compatible object storage. Published pages reference those files through your tenant's own origin under /cdn/... paths instead of the underlying storage URL. The /cdn/ route is a thin proxy back to the CMS Delivery API — @glokki-cms/sdk/asset-proxy provides it as a drop-in.
1. Why This Exists
The CMS dashboard and your tenant app run on different origins (e.g. cms.editor.app and acme.com). Uploaded assets live in object storage at a third origin (e.g. s3.example.com, cdn.digitaloceanspaces.com, or localhost:9000 for MinIO).
If published HTML referenced the storage URL directly, three problems follow:
- Storage layout leaks — every page exposes the bucket name and tenant ID prefix (
tenants/{uuid}/...). - Cache headers belong to S3, not your CDN — you cannot control caching, set custom
Cache-Control, or invalidate via your edge. - Cross-origin requests — every
<img>triggers a separate connection to the storage host, with its own CORS rules and TLS handshake.
The fix: tenant pages reference assets under their own origin (/cdn/foo.jpg), and a tiny route handler proxies those requests back to the CMS Delivery API, which streams from storage with full HTTP semantics preserved.
2. The URL Pipeline
┌─────────────────────────────────────────────────────────────────┐
│ CMS dashboard (origin A: cms.editor.app) │
│ │
│ Picker → http://s3.example.com/bucket/tenants/{id}/foo.jpg │
│ (absolute URL, used so the preview image loads in the │
│ props panel from an origin the dashboard can reach) │
│ │
│ Component tree stored in DB with the absolute URL. │
│ │
│ Delivery API (read-time only): │
│ Walks the whole props tree, rewrites every URL pointing at │
│ tenant storage to `/cdn/foo.jpg?w&h&fp` (dimensions + │
│ object-position). │
└─────────────────────────────────────────────────────────────────┘
│
│ Delivery API response carries
│ `src: "/cdn/foo.jpg"`
▼
┌─────────────────────────────────────────────────────────────────┐
│ Tenant Next.js app (origin B: acme.com) │
│ │
│ Renders <img src="/cdn/foo.jpg" />. │
│ Browser issues GET acme.com/cdn/foo.jpg. │
└─────────────────────────────────────────────────────────────────┘
│
│ Same origin — hits the proxy route
▼
┌─────────────────────────────────────────────────────────────────┐
│ app/cdn/[...path]/route.ts │
│ │
│ createAssetProxy() forwards the request to │
│ `${CMS_BASE_URL}/api/v1/delivery/assets/foo.jpg` │
│ with `Authorization: Bearer ${CMS_API_KEY}` and │
│ `X-Tenant-Slug: ${CMS_TENANT}`. │
│ │
│ CMS resolves the tenant, looks up the asset, streams it from │
│ object storage, and returns headers + body. │
│ Range and ETag headers are passed through both │
│ directions, so partial-content (206) and conditional (304) │
│ requests work end-to-end. │
└─────────────────────────────────────────────────────────────────┘
The crucial insight: the URL rewrite from absolute to /cdn/... is a read-time transform inside the CMS, applied on every fetch of the component tree. Nothing is persisted as /cdn/... in the database — the absolute URL stays canonical, the relative form is derived.
This means:
- The CMS dashboard's props panel shows the absolute URL right after a picker selection. It only collapses to
/cdn/...on the next SSR load of the editor page (after autosave + refresh). - The Delivery API always serves the
/cdn/...form to consumers. - Changing an asset's focal point or regenerating variants takes effect on the next read with no re-save of the page itself, because the derived data is rebuilt every time.
3. Quickstart
Add a single route file to your tenant Next.js app:
// app/cdn/[...path]/route.ts
import { createAssetProxy } from "@glokki-cms/sdk/asset-proxy"
const handler = createAssetProxy()
export const GET = handler
export const HEAD = handler
That's it. The factory reads CMS_BASE_URL, CMS_TENANT, and CMS_API_KEY from process.env and returns an async route handler.
For non-Next frameworks or multi-tenant setups, pass the options explicitly:
const handler = createAssetProxy({
baseUrl: "https://cms.editor.app",
tenantSlug: "acme",
apiKey: process.env.CMS_API_KEY,
})
4. Required Environment Variables
| Variable | Required | Description |
|---|---|---|
CMS_BASE_URL | Yes | URL of the CMS deployment (e.g. http://localhost:3000, https://cms.editor.app). Trailing slashes are stripped. |
CMS_TENANT | Yes | Your tenant slug. Sent as the X-Tenant-Slug header so the CMS resolves the correct tenant. |
CMS_API_KEY | Yes | Delivery API key (CMS Dashboard → Settings → API Keys). Sent as Authorization: Bearer .... |
These are the same three variables used by the SDK's createClient() — if you already wired up the integration in Integration Guide, you don't need new secrets.
Since @glokki-cms/sdk 0.17.0 the proxy reads them on each request, not when the route module loads. next build loads every route module during its "Collecting page data" step, so the build no longer needs the variables. A request that finds one missing gets a 500 and a server log line such as [asset-proxy] createAssetProxy: baseUrl is required (set CMS_BASE_URL or pass opts.baseUrl).
Older SDKs (before 0.17.0) check the variables when
createAssetProxy()is called. Called at the top level of the route module, as in the Quickstart, that failsnext buildwithcreateAssetProxy: baseUrl is required ...whenever the build environment lacks the variables (common in CI). Upgrade, or make the variables available to the build step.
5. What createAssetProxy() Does
For each GET or HEAD request to /cdn/<path>:
- Joins the
[...path]segments into a filename, percent-encoding each one so names containing#,?or%survive the hop. - Builds
${CMS_BASE_URL}/api/v1/delivery/assets/${filename}. - Forwards
AuthorizationandX-Tenant-Slugheaders. - Forwards the client's
RangeandIf-None-Matchheaders so partial-content (206) and conditional (304) responses pass through. - Streams the response body back to the browser without buffering.
- Whitelists response headers (
content-type,content-length,content-range,cache-control,etag,accept-ranges,last-modified,x-content-type-options) — everything else is dropped. - Adds
CDN-Cache-Controlto immutable200responses (see Caching).
On upstream failure (network error, DNS miss), the proxy returns 502 Bad Gateway. Other HTTP statuses (404 for unknown assets, 401 for bad API key) pass through unchanged.
Caching
The CMS decides the caching policy and the proxy passes its Cache-Control header through unchanged:
| Response | Cache-Control from the CMS |
|---|---|
| Normal asset (original or variant) | public, max-age=31536000, immutable |
Degraded response — the CMS could not resolve a ?w variant and served the original instead | public, max-age=60 |
Asset URLs are content-addressed — a file is never overwritten under the same path — so a one-year immutable cache is safe. The short degraded policy exists so the proper variant replaces the heavier original soon after the CMS recovers.
The CMS sends max-age only, which most CDNs placed in front of your app (Cloudflare, Fastly) use for their edge cache too. Some don't: Vercel's CDN does not cache a function response on max-age alone — it needs s-maxage, CDN-Cache-Control or Vercel-CDN-Cache-Control. So the proxy copies the CMS's Cache-Control into CDN-Cache-Control (RFC 9213) on full 200 responses whose policy is immutable. Partial 206 responses, 304s, errors and the short-lived degraded responses get no CDN-Cache-Control, so a fallback is never pinned at the edge for longer than the CMS intended. You do not need to configure anything.
Hosting on Vercel with an SDK older than 0.17.0
Older proxies forward Cache-Control only, so on Vercel every /cdn/ request runs your route and fetches the file from the CMS. Upgrade @glokki-cms/sdk, or wrap the handler:
// lib/with-cdn-cache.ts
import type { AssetProxyHandler } from "@glokki-cms/sdk/asset-proxy"
/** Mirrors an immutable 200's Cache-Control into CDN-Cache-Control. */
export function withCdnCache(handler: AssetProxyHandler): AssetProxyHandler {
return async (request, ctx) => {
const response = await handler(request, ctx)
const cacheControl = response.headers.get("cache-control")
if (response.status !== 200 || !cacheControl?.includes("immutable")) {
return response
}
const headers = new Headers(response.headers)
headers.set("CDN-Cache-Control", cacheControl)
return new Response(response.body, { status: response.status, headers })
}
}
and export withCdnCache(createAssetProxy()) (and withCdnCache(createOgProxy()) for OG images) as GET and HEAD.
6. Query Parameters
The Delivery API rewrites every image URL in a block's props to a /cdn/
path — regardless of the prop's name, whether it sits in an array, or how
deeply it is nested inside arrays of objects. Detection is by URL shape: any
string pointing at tenants/{id}/... storage is rewritten. There is no
prop-name convention to follow.
What gets rewritten to /cdn/
| Part of the delivery response | Rewritten? | Notes |
|---|---|---|
Block props (componentTree[].props, at any depth) | Yes | Any string whose entire value is a tenant storage URL (or an existing /cdn/ path). A string containing whitespace or < — e.g. a rich-text/HTML prop that merely embeds an asset URL — is left untouched. |
Per-viewport overrides (viewportOverrides) | No | Not rewritten yet — see Troubleshooting. |
Layout header/footer data (layout.header, layout.footer) | Yes | Same rule as block props, e.g. a logo src in your header. |
collectionData — image and file fields | Yes | These fields store an asset ID, which is resolved to the same /cdn/<file>?w&h&fp URL. If the asset no longer exists, the raw ID is returned. |
collectionData — every other field | No | Passed through as stored. |
seo.og.image | No | The generated social card is an absolute /og/ URL (see OG Image Proxy). An image set manually by an editor is returned exactly as stored. |
seo.twitter.image | No | Returned exactly as stored. |
Derived data in the query string
Each rewritten URL carries derived data in its query string:
| Param | Meaning | Consumed by |
|---|---|---|
w | The asset's natural width in pixels | the /cdn/ endpoint, to select a variant |
h | The asset's natural height in pixels | the block, for layout stability |
fp | Focal point as x,y fractions (0–1) | the block, as CSS object-position |
Rendering with a <CdnImage> component
@glokki-cms/react (0.10.0 and later) exports CdnImage. Use it in any block that renders an image prop. For a /cdn/ URL it builds a responsive srcSet by re-stamping ?w, and applies ?fp as object-position. Any other URL (a placeholder, an external image) renders as a plain <img>.
// blocks/hero.tsx
import { CdnImage } from "@glokki-cms/react"
export function Hero({ image, title }: { image: string; title: string }) {
return (
<section className="relative h-[60vh]">
<CdnImage src={image} alt={title} fill sizes="100vw" loading="eager" />
</section>
)
}
| Prop | Description |
|---|---|
src, alt | Required. src is the prop value from the CMS. |
width, height | Reserve layout space. Ignored with fill. |
fill | Absolutely positions the image to fill its (positioned) parent. |
sizes | Passed to the <img>; defaults to 100vw for /cdn/ URLs. |
loading | "lazy" (default) or "eager". |
format | "webp" or "avif", stamped as ?f on every candidate. Omitted, the endpoint serves WebP. |
className, style | Passed through; a focal point is merged into style as object-position. |
For src="/cdn/hero.jpg?w=2400&h=1600&fp=0.25,0.75" it renders:
src="/cdn/hero.jpg?w=1920"srcset="/cdn/hero.jpg?w=480 480w, /cdn/hero.jpg?w=1024 1024w, /cdn/hero.jpg?w=1920 1920w"style="object-position: 25.00% 75.00%"
Pass the asset's natural dimensions (the w and h values from the URL) or your block's own dimensions as width/height to reserve layout space, and pass sizes whenever the image is narrower than the viewport — the default 100vw makes the browser pick the widest variant. For a hero image that is the page's largest element, pass loading="eager" so it is not lazy-loaded.
Variants are served as WebP by default. Pass format="avif" to serve AVIF instead — only if every browser you target supports AVIF, because there is no automatic negotiation (see Format selection below).
Variant selection — ?w
GET /cdn/foo.jpg?w=800 makes the delivery endpoint stream the smallest
pre-generated variant at least 800px wide (or the widest, if none is larger).
Without ?w, the original file is streamed unchanged. A request for an asset
with no processed variants falls back to the original — a missing variant is
never a 404.
Format selection — ?f
?f picks the variant format: ?f=avif for AVIF, anything else (or absent)
for WebP. The format lives in the URL — and therefore in the cache key — so no
Vary: Accept header is needed. There is no automatic per-browser
negotiation; ?f=avif is an explicit opt-in.
7. OG Image Proxy
Every published page gets a generated social card — the image crawlers show when the page is shared — and the Delivery API returns its URL in seo.og.image. By default that URL points at the CMS origin. To serve cards from your own domain instead, add a second route next to /cdn/:
// app/og/[...path]/route.ts
import { createOgProxy } from "@glokki-cms/sdk/og-proxy"
const handler = createOgProxy()
export const GET = handler
export const HEAD = handler
createOgProxy() works exactly like createAssetProxy():
- It reads the same three variables —
CMS_BASE_URL,CMS_TENANT,CMS_API_KEY— or takes{ baseUrl, tenantSlug, apiKey }explicitly. No new secrets. - It reads them per request (SDK 0.17.0+), so
next builddoesn't need them — see Required Environment Variables. - It forwards
If-None-Match, streams the PNG back, passes through the same response-header allowlist (includingCache-Control, plusCDN-Cache-Controlon immutable cards), and returns502when the CMS is unreachable. Upstream failures are logged with the prefix[og-proxy].
Turning it on in the CMS
Deploying the route is not enough — the CMS keeps emitting CMS-origin URLs until you switch it over:
- Make sure your site's address is set in CMS Dashboard → Settings → Visual Editor → Frontend URL. The tenant-domain URL is built from it; while it is empty, cards keep being served from the CMS origin even with the toggle on.
- Deploy the
/og/[...path]route above. - In CMS Dashboard → Settings → Social Cards, turn on Serve social cards from your own domain (in the Delivery domain card). The card previews the host that will appear in
og:image.
| Toggle | seo.og.image in the delivery response |
|---|---|
| Off (default) | https://<cms-host>/og/<tenantId>/<pageId>/<hash>.png |
| On, Frontend URL set | https://<your-frontend-url>/og/<pageId>/<hash>.png |
| On, no Frontend URL | Same as Off |
Both forms are absolute URLs, as og:image requires — unlike asset URLs, they are not /cdn/-relative. The tenant-domain form carries no tenant ID; the proxy's API key identifies the tenant.
Two cases where the generated card is not used:
- A manual image wins. If an editor set an OG image on the page,
seo.og.imageis that image, returned exactly as stored, and no card URL is emitted. - Preview responses (
?preview=true&token=...) do not include a generated card.
Caching
<hash> is derived from everything that goes into the card (template, title and other text, background image, locale), so editing the page or its template produces a new URL rather than new bytes at the old one. A successfully generated card is therefore served with Cache-Control: public, max-age=31536000, immutable. If generation falls back to a degraded render, the response gets public, max-age=60 instead, so the real card replaces it soon.
The proxy adds CDN-Cache-Control to immutable cards exactly as it does for assets, so Vercel's CDN caches them; a degraded card is left to expire.
8. Verifying the Setup
-
Upload an asset through the CMS dashboard's media library.
-
Add a block with an image field and pick the upload.
-
Save and reload the page editor. The props panel value should now read
/cdn/<filename>?w&h…instead of an absolute URL. (Reload is required — enrichment is read-time only.) -
Open your tenant app at the published page's URL. Inspect the rendered
<img>— thesrcattribute should be/cdn/<filename>?w…and the<img>should carry asrcset. -
Open DevTools → Network and load the image. The request should hit
/cdn/...on your tenant origin, return200 OK, and carryCache-Control: public, max-age=31536000, immutablefrom the CMS. -
Confirm your CDN caches it. Request the same URL twice and check the cache-status header your CDN adds. The response should carry
CDN-Cache-Controlwith the same value, and on Vercel the second request should showx-vercel-cache: HIT:curl -sI "https://acme.com/cdn/foo.jpg?w=1024" | grep -i -E "cache-control|x-vercel-cache" curl -sI "https://acme.com/cdn/foo.jpg?w=1024" | grep -i x-vercel-cache # Expect on the second request: x-vercel-cache: HITMISSon every request with noCDN-Cache-Controlheader means an SDK older than 0.17.0 — upgrade, or see the wrapper. A response withCache-Control: public, max-age=60is a degraded response and is deliberately not cached long. -
Check the social card (if you set up the OG image proxy). Fetch a published page from the Delivery API and open its
seo.og.imageURL: it should start with your frontend URL, return200withContent-Type: image/png, and — on Vercel — showx-vercel-cache: HITon the second request. -
Test range support (optional, mostly for video):
curl -I -H "Range: bytes=0-1023" https://acme.com/cdn/big.mp4 # Expect: 206 Partial Content # Content-Range: bytes 0-1023/<total> -
Test conditional GET (optional):
curl -I -H 'If-None-Match: "<etag-from-previous-response>"' https://acme.com/cdn/foo.jpg # Expect: 304 Not Modified
9. Troubleshooting
404 on /cdn/... requests
Either the route file is missing, or Next.js hasn't picked it up. Check:
app/cdn/[...path]/route.tsexists at the project root, not inside a route group.- The file exports
GET(and ideallyHEAD). - The dev server has restarted since the file was added.
502 Bad Gateway
The proxy reached your tenant app but the upstream fetch to CMS_BASE_URL failed. Common causes:
CMS_BASE_URLpoints at an unreachable host (e.g. a Docker-internal name from a non-Docker dev server).- The CMS is not running.
- TLS misconfiguration on the CMS host.
Check the tenant app server logs — createAssetProxy() logs upstream fetch errors to console.error with the prefix [asset-proxy].
401 Unauthorized from upstream
CMS_API_KEY is invalid or revoked, or CMS_TENANT doesn't match a tenant in the CMS. Regenerate the key in CMS Dashboard → Settings → API Keys and confirm the slug.
Absolute storage URL appears in the editor
You're looking at the editor immediately after picking an image. The picker writes an absolute URL into the working tree; enrichment runs only at SSR load. Save (autosave will fire) and reload the editor — the value should collapse to /cdn/....
If it's still absolute after a reload, the CMS couldn't match the URL to an asset in your tenant's media library. Check:
- The asset was uploaded to this tenant's media library — URLs pointing at another tenant's storage are left untouched.
- The URL contains
tenants/{tenantId}/...— only URLs of that shape are recognised as tenant storage.
Storage URL appears un-rewritten
Only URLs containing tenants/{tenantId}/... are recognised as tenant storage. If the URL does not match (e.g. it is already a relative path or an external CDN URL), the CMS skips it by design.
Values set as per-viewport overrides (tablet/mobile) are not rewritten yet — an image picked only in a viewport override ships its absolute storage URL. Set the image on the base (desktop) props instead.
Social card URL points at the CMS, or /og/... returns 404
seo.og.imagestill uses the CMS host: the Serve social cards from your own domain toggle is off, or no Frontend URL is set in Settings → Visual Editor./og/...on your site returns404for every page: theapp/og/[...path]/route.tsfile is missing (or sits inside a route group). Turning the toggle on before the route is deployed breaks every shared link preview until it is.404for one page only: the hash in the URL is stale — the page or its template changed since the URL was fetched. Re-fetch the page from the Delivery API for the current URL.
Range requests return 200 OK instead of 206
The accept-ranges header is in the passthrough whitelist, but if your CDN/edge strips it, the browser won't send range requests. Confirm accept-ranges: bytes is present on the response from your tenant origin (not just from the CMS).
Related Guides
- Integration Guide — overall SDK setup, including
CMS_BASE_URL/CMS_TENANT/CMS_API_KEYenv vars. - Block Development — schema-to-editor field mapping, including
fieldType: "image".