glokki
View .md

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

VariableRequiredDescription
CMS_BASE_URLYesURL of the CMS deployment (e.g. http://localhost:3000, https://cms.editor.app). Trailing slashes are stripped.
CMS_TENANTYesYour tenant slug. Sent as the X-Tenant-Slug header so the CMS resolves the correct tenant.
CMS_API_KEYYesDelivery 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 fails next build with createAssetProxy: 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>:

  1. Joins the [...path] segments into a filename, percent-encoding each one so names containing #, ? or % survive the hop.
  2. Builds ${CMS_BASE_URL}/api/v1/delivery/assets/${filename}.
  3. Forwards Authorization and X-Tenant-Slug headers.
  4. Forwards the client's Range and If-None-Match headers so partial-content (206) and conditional (304) responses pass through.
  5. Streams the response body back to the browser without buffering.
  6. Whitelists response headers (content-type, content-length, content-range, cache-control, etag, accept-ranges, last-modified, x-content-type-options) — everything else is dropped.
  7. Adds CDN-Cache-Control to immutable 200 responses (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:

ResponseCache-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 insteadpublic, 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 responseRewritten?Notes
Block props (componentTree[].props, at any depth)YesAny 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)NoNot rewritten yet — see Troubleshooting.
Layout header/footer data (layout.header, layout.footer)YesSame rule as block props, e.g. a logo src in your header.
collectionData — image and file fieldsYesThese 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 fieldNoPassed through as stored.
seo.og.imageNoThe 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.imageNoReturned exactly as stored.

Derived data in the query string

Each rewritten URL carries derived data in its query string:

ParamMeaningConsumed by
wThe asset's natural width in pixelsthe /cdn/ endpoint, to select a variant
hThe asset's natural height in pixelsthe block, for layout stability
fpFocal 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>
  )
}
PropDescription
src, altRequired. src is the prop value from the CMS.
width, heightReserve layout space. Ignored with fill.
fillAbsolutely positions the image to fill its (positioned) parent.
sizesPassed 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, stylePassed 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 build doesn't need them — see Required Environment Variables.
  • It forwards If-None-Match, streams the PNG back, passes through the same response-header allowlist (including Cache-Control, plus CDN-Cache-Control on immutable cards), and returns 502 when 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:

  1. 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.
  2. Deploy the /og/[...path] route above.
  3. 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.
Toggleseo.og.image in the delivery response
Off (default)https://<cms-host>/og/<tenantId>/<pageId>/<hash>.png
On, Frontend URL sethttps://<your-frontend-url>/og/<pageId>/<hash>.png
On, no Frontend URLSame 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.image is 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

  1. Upload an asset through the CMS dashboard's media library.

  2. Add a block with an image field and pick the upload.

  3. 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.)

  4. Open your tenant app at the published page's URL. Inspect the rendered <img> — the src attribute should be /cdn/<filename>?w… and the <img> should carry a srcset.

  5. Open DevTools → Network and load the image. The request should hit /cdn/... on your tenant origin, return 200 OK, and carry Cache-Control: public, max-age=31536000, immutable from the CMS.

  6. 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-Control with the same value, and on Vercel the second request should show x-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: HIT
    

    MISS on every request with no CDN-Cache-Control header means an SDK older than 0.17.0 — upgrade, or see the wrapper. A response with Cache-Control: public, max-age=60 is a degraded response and is deliberately not cached long.

  7. 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.image URL: it should start with your frontend URL, return 200 with Content-Type: image/png, and — on Vercel — show x-vercel-cache: HIT on the second request.

  8. 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>
    
  9. 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.ts exists at the project root, not inside a route group.
  • The file exports GET (and ideally HEAD).
  • 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_URL points 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.image still 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 returns 404 for every page: the app/og/[...path]/route.ts file 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.
  • 404 for 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).


  • Integration Guide — overall SDK setup, including CMS_BASE_URL / CMS_TENANT / CMS_API_KEY env vars.
  • Block Development — schema-to-editor field mapping, including fieldType: "image".