Documentation Menu

Assets

8 min read

Every file a document uses arrives inlined in its body as an Asset, with a url to render, and the SDK serves private files from your own site so readers never see the CMS or a token.

Why a page needs more than the file's id

When a content manager picks a file in the media library, the document stores a pointer to it and nothing more. A browser needs more than a pointer. It needs an address it is allowed to load, the image's size, alt text, and smaller copies for small screens. A private file also needs a gate, so that only the readers your site allows can open it. The SDK turns each pointer into what a page needs. It also gives you a route that streams private files with the site's own key, behind whatever check your site makes.

If you are a content manager, this is what Private means for a file in Media: it is served only through a link that expires or through your own site, and the site decides, reader by reader, who gets it.

What an asset carries and where its url points

Wherever a body held { "$asset": "<id>" }, get and walk put an Asset in its place:

Field What it holds
id The file's id, 26 characters.
url What to render. Decided by assets.mode, below.
originUrl What the CMS answered: its own URL for a public file, a signed and expiring one for a private file, null when the key may not read it.
name, mime, size The file's name, type and size in bytes.
width, height An image's pixels, or null.
alt, caption, decorative The description for this use of the file: the document's own, else the file's. decorative: true means draw it with empty alt text.
visibility public or private.
variants For an image the CMS can scale: the widths it offers and the formats (webp, avif, jpeg, png), else null.

Where url points depends on assets.mode:

  • proxy, the default. A private file's url is your own /media/<id> (or whatever assets.path says), and your site streams the file. A public file's url is the CMS's own address, unless assets.proxyPublic is true, which sends public files through your site too.
  • signed. Here url is originUrl: the CMS's own address, with a signature on it for a private file. You need no proxy, and whoever holds the link can open it until it expires, six to twelve hours after the read that minted it.

A delivery key receives a private file's link only if it holds the assets:private scope. Without that scope the Asset still arrives with its name and size, but originUrl and url are null, which leaves the page free to draw a placeholder.

The client's assets helpers each take an Asset:

  • q.assets.url(asset, { w, f }) appends a width and a format with the right separator: & after a signed link's own query, ? otherwise.
  • q.assets.srcset(asset, { widths, format }) builds a srcset from the widths the image offers, or from the ones among them that you name.
  • q.assets.signedUrl(asset) returns originUrl in either mode, for a link you want to email or hand to another service.

The proxy comes in three shapes, and all three behave the same way. createAssetHandler(q) from @quilletcms/sdk takes a Request and returns a Response. nodeAssetListener(q) from @quilletcms/sdk/node is for http.createServer or Express. assetRoute(q) comes from @quilletcms/sdk/next. Whichever you use, the proxy answers only GET and HEAD (anything else gets 405). It takes the id from the path under assets.path, passes w, f, Range and If-None-Match on to the CMS, and fetches with the delivery key. A file that does not exist and a file the key may not read both answer 404, so a reader learns nothing from the difference. A public file keeps the CMS's year-long Cache-Control. A private one is sent with private, max-age= set to assets.maxAge, 300 seconds by default.

How to

If any document uses a private file, the site's delivery key needs the extra scope. Mint it under Settings → Keys → New key, with Kind set to Delivery, and tick assets:private under Scopes.

Render an image with its size, its description and a srcset. The browser then picks the smallest copy that fills the slot:

// hero.ts: an <img> for the hero image of pages/home.
import type { Asset } from '@quilletcms/sdk'
import { quillet } from '@quilletcms/sdk/node'

interface Home {
  hero: { headline: string; image?: Asset }
}

const q = quillet()
const escape = (text: string) => text.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`)

export function img(asset: Asset, sizes = '100vw'): string {
  if (asset.url === null) return '<span class="placeholder">Image unavailable</span>'
  const alt = asset.decorative ? '' : (asset.alt ?? '')
  const size = asset.width && asset.height ? ` width="${asset.width}" height="${asset.height}"` : ''
  return `<img src="${escape(asset.url)}" srcset="${escape(q.assets.srcset(asset, { format: 'webp' }))}" sizes="${sizes}" alt="${escape(alt)}"${size}>`
}

const home = await q.get<Home>('pages/home')
if (home.content.hero.image) console.log(img(home.content.hero.image))

To serve private files from a Node server, check the reader first. The listener itself streams whatever it is asked for:

// server.ts: /media/<id> streams files, for signed-in readers only.
import { createServer } from 'node:http'
import { nodeAssetListener, quillet } from '@quilletcms/sdk/node'

const q = quillet()
const media = nodeAssetListener(q)

const signedIn = (cookie: string | undefined) => cookie?.includes('session=') ?? false

createServer((request, response) => {
  if (request.url?.startsWith('/media/')) {
    if (!signedIn(request.headers.cookie)) {
      response.writeHead(404).end()
      return
    }
    media(request, response)
    return
  }
  response.writeHead(200, { 'Content-Type': 'text/plain' }).end('the rest of the site')
}).listen(4000)

With the server running, ask it for a private file by the id in that file's Asset:

curl -s -I -H 'Cookie: session=<anything>' http://localhost:4000/media/<asset-id>

The answer carries the file's own Content-Type, Content-Length and ETag, along with Cache-Control: private, max-age=300. Send the same request without the cookie and it gets a 404.

A server built on Request and Response can use the handler directly:

// worker.ts: the same proxy for any fetch-style server.
import { createAssetHandler } from '@quilletcms/sdk'
import { quillet } from '@quilletcms/sdk/node'

const q = quillet()
const media = createAssetHandler(q)

export async function handle(request: Request): Promise<Response> {
  const { pathname } = new URL(request.url)
  if (pathname.startsWith('/media/')) return media(request)
  return new Response('Not found', { status: 404 })
}

To keep every image on your own domain, public ones included, turn on proxyPublic. After that, every url starts with /media/:

// same-origin.ts: no CMS host in the HTML at all.
import { quillet } from '@quilletcms/sdk/node'

export const q = quillet({ assets: { proxyPublic: true } })

To do without the proxy, switch to signed links. Render them only into pages that are short-lived themselves, since a link stays good until it expires:

// signed.ts: private files as the CMS's own expiring links.
import type { Asset } from '@quilletcms/sdk'
import { quillet } from '@quilletcms/sdk/node'

const q = quillet({ assets: { mode: 'signed' } })
const home = await q.get<{ payslip?: Asset }>('pages/home')

const link = home.content.payslip ? q.assets.signedUrl(home.content.payslip) : null
console.log(link ?? 'this key cannot read the file')

In Next.js the proxy is a route file, and Next.js shows it.

Limits

  • The proxy does not decide who may see a file. It streams any file the key may read, and the check in front of it is yours to write.
  • A signed link carries no per-reader check, and you cannot revoke links one at a time. Whoever holds a link can open it until it expires. Rotating the CMS's ASSET_SIGNING_SECRET retires every link at once.
  • Making a private file public does not retire a signed link already handed out. That link stays good until it expires, which can take up to twelve hours.
  • Making a public file private stops the CMS serving it at once. A copy that a CDN or a browser has already cached stays readable for up to a year, though, because public files are served immutable for a year.
  • One answer inlines at most 500 distinct files. Past that, the remaining pointers stay as written, { "$asset": "<id>" }, and _meta.assetsTruncated is true. Check it on any document that holds a large gallery.
  • w and f are ignored on an SVG, on an animated GIF and on anything that is not a raster image; the original is served instead. A width the CMS does not offer is refused with 422, so build srcset from variants.widths rather than from widths of your own.
  • The image is never scaled up. Ask for a width larger than the original and you get the original's size.
  • srcset falls back to the plain url when the image offers no widths, and to an empty string when url is null.
  • The SDK cannot upload, replace or describe files. For that, use Media in the dashboard, or quillet assets on the command line.
  • Files in a Markdown body take a separate path and are always public. Variables and Markdown covers them.
  • Files and media: why files are project-wide, and public or private.
  • Media: uploading and describing files in the dashboard.
  • File storage: the CMS's image widths and the signing secret.
  • Configure the SDK: assets.mode, assets.path, assets.proxyPublic and assets.maxAge.
  • Next.js: assetRoute as an App Router route.
  • Delivery API: the bytes route the proxy reads.

Last updated