Assets
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'surlis your own/media/<id>(or whateverassets.pathsays), and your site streams the file. A public file'surlis the CMS's own address, unlessassets.proxyPublicistrue, which sends public files through your site too.signed. HereurlisoriginUrl: 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 asrcsetfrom the widths the image offers, or from the ones among them that you name.q.assets.signedUrl(asset)returnsoriginUrlin 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_SECRETretires 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
immutablefor a year. - One answer inlines at most 500 distinct files. Past that, the remaining pointers stay as written,
{ "$asset": "<id>" }, and_meta.assetsTruncatedistrue. Check it on any document that holds a large gallery. wandfare 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 buildsrcsetfromvariants.widthsrather 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.
srcsetfalls back to the plainurlwhen the image offers no widths, and to an empty string whenurlisnull.- The SDK cannot upload, replace or describe files. For that, use Media in the dashboard, or
quillet assetson the command line. - Files in a Markdown body take a separate path and are always public. Variables and Markdown covers them.
Related
- 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.proxyPublicandassets.maxAge. - Next.js:
assetRouteas an App Router route. - Delivery API: the bytes route the proxy reads.