---
title: "Asset CDN Proxy"
url: "https://glokki.com/docs/asset-cdn-proxy"
locale: "en"
updated: "2026-10-14T21:07:07.400Z"
---

# 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.

---

## Table of Contents

1. [Why This Exists](#1-why-this-exists)
2. [The URL Pipeline](#2-the-url-pipeline)
3. [Quickstart](#3-quickstart)
4. [Required Environment Variables](#4-required-environment-variables)
5. [What `createAssetProxy()` Does](#5-what-createassetproxy-does)
6. [Query Parameters](#6-query-parameters)
7. [OG Image Proxy](#7-og-image-proxy)
8. [Verifying the Setup](#8-verifying-the-setup)
9. [Troubleshooting](#9-troubleshooting)

---

## 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:

```ts
// 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:

```ts
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](/docs/integration), 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](#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](https://www.rfc-editor.org/rfc/rfc9213)) on full `200` responses whose policy is `immutable`. Partial `206` responses, `304`s, 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:

```ts
// 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](#7-og-image-proxy)) 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](#storage-url-appears-un-rewritten).                                                                                                                                    |
| 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](#7-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>`.

```tsx
// 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](#format-selection--f) 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/`:

```ts
// 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](#4-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`.

| 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.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`:

   ```bash
   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](#hosting-on-vercel-with-an-sdk-older-than-0170). 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](#7-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):

   ```bash
   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):

   ```bash
   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).

---

## Related Guides

- [Integration Guide](/docs/integration) — overall SDK setup, including `CMS_BASE_URL` / `CMS_TENANT` / `CMS_API_KEY` env vars.
- [Block Development](/docs/block-development) — schema-to-editor field mapping, including `fieldType: "image"`.
