Skip to content

Images and media

Media in Trokky are real files in storage you control, plus a metadata record. A document does not contain the file; it contains a reference to it.

{
"_type": "media",
"asset": { "_ref": "media-8f3a1c", "_type": "mediaAsset" },
"alt": "Harvest at dawn",
"caption": "Kolda, 2026",
"variant": "medium"
}

asset._ref is the media id, and it is the only part you need to build a URL. alt, caption, title and variant belong to this use of the asset — the same image referenced from two documents can carry different alt text in each. The asset’s own metadata (filename, size, dimensions) lives on the media record, not here.

Editors upload in the Studio. To upload from code, post multipart form data:

Terminal window
curl -X POST http://localhost:3000/api/media/upload \
-H "Authorization: Bearer $TROKKY_API_TOKEN" \
-F "file=@cover.jpg"
{
"success": true,
"data": { "files": [{ "id": "media-8f3a1c", "filename": "cover.jpg", "contentType": "image/jpeg", "size": 184320 }], "meta": { "count": 1 } }
}

The response is 201. The form field name does not matter — every file part in the request is uploaded, up to ten per request. Each file must be 100 MB or smaller and carry an allowed content type: images (JPEG, PNG, GIF, WebP, SVG), video (MP4, WebM, MOV, AVI), audio (MP3, WAV, OGG, AAC, M4A, FLAC, WebM), PDF, Word documents, plain text, CSV and JSON. Anything else is a 400.

Two rejections surprise people. The extension check looks at every dot-separated segment, so report.js.pdf is refused for containing .js. And a filename containing /, \ or .. is refused outright.

The SDK’s client.uploadFile() posts to /media, where no upload route exists, so it returns a 404. Upload over HTTP as above until that is fixed.

Two routes serve bytes:

GET /api/media/:id/file
GET /api/media/:id/variants/:name

Both respond with the file’s content type, Cache-Control: public, max-age=31536000 and an ETag. Neither checks authentication — unlike every other endpoint, media files are public to anyone who knows the id. That is what makes <img src> work without a token, and it means a private draft’s image is readable even while the document is not. Plan for that before you put anything confidential in the media library.

Requesting a variant that was never generated returns 404, not the original.

The metadata routes — GET /api/media, GET /api/media/:id, PUT, DELETE, and POST /api/media/:id/regenerate-variants — do require a token.

const url = client.imageUrl(post.cover).width(800).format('webp').quality(85).url()
// http://localhost:3000/api/media/media-8f3a1c/file?w=800&fm=webp&q=85

imageUrl() accepts a media field value or a bare media id, and returns null when the value is empty — so an empty field produces no URL rather than a broken one.

The chainable options are width, height, size(w, h), format, quality, fit, blur, sharpen, grayscale, auto and variant, plus thumbnail(), medium(), large() and original(). They map to w, h, fm, q, fit, blur, sharp and grayscale=1. Call url() to get the string, or interpolate the builder directly — toString() returns the URL or an empty string.

variant() is not a modifier, it is a different URL. Setting it sends you to /variants/<name> and every other transform you chained is dropped:

client.imageUrl(post.cover).variant('thumbnail').width(400).url()
// .../media/media-8f3a1c/variants/thumbnail — the width is gone

original() is the exception: it means “no variant” and resolves to /file.

For a responsive image, getSrcSet builds the whole attribute:

import { getSrcSet } from '@trokky/client'
const srcset = getSrcSet(post.cover, {
baseUrl: 'http://localhost:3000/api',
widths: [400, 800, 1200],
})
// ".../file?w=400 400w, .../file?w=800 800w, .../file?w=1200 1200w"

Pass variants: ['thumbnail', 'medium', 'large'] instead of widths to point at generated variants; their widths are assumed to be 300, 800 and 1200.

?w=800&fm=webp is a request, not a guarantee. The Express server Trokky ships does not read those parameters at all: GET /media/:id/file returns the stored file, with its original dimensions and content type, and a 200.

Deployments that put an image pipeline in front — a CDN, an object store that transforms on read — can honour them. Deployments that do not, silently do not.

So the parameters are safe to emit and unwise to rely on. If you need three sizes of an image, generate three variants.

Variants are generated once, at upload time, from your configuration:

media: {
processor: 'sharp',
variants: [
{ name: 'thumbnail', width: 300, height: 200, format: 'webp', quality: 80, fit: 'cover' },
{ name: 'medium', width: 800, height: 600, format: 'webp', quality: 85, fit: 'inside' },
],
}

The names are yours. thumbnail(), medium() and large() on the URL builder are shorthand for those three names — if your config does not define them, those methods produce URLs that 404.

Add or change a variant and existing files do not get it; only new uploads do. POST /api/media/:id/regenerate-variants rebuilds them for one file, and refuses with a 400 for anything that is not an image. There is no bulk regeneration.

Which variants a file actually has is on its metadata record, under metadata.imageVariants. client.media exposes that: getAvailableVariants(), hasVariant(), getAllVariantUrls(), and getValidatedUrl(), which checks the variant exists and falls back to the thumbnail and then the original. Those methods each cost an extra API call, which is fine at build time and less fine per request.

See Configuration for where the media block lives, and Storage adapters for where the files end up.

Serving images straight from the CMS means the browser learns your CMS hostname, and any custom domain or CDN you put in front applies to the site but not the images. A proxy route on your own origin avoids both:

src/pages/media/[...path].ts
import { createAstroMediaProxy } from '@trokky/client/server'
export const GET = createAstroMediaProxy({
apiUrl: import.meta.env.TROKKY_API_URL,
apiToken: import.meta.env.TROKKY_API_TOKEN,
})

The route must be a catch-all, because the paths it forwards have two shapes: <id>/file and <id>/variants/<name>. It fetches ${apiUrl}/media/${path} with the token attached and returns the bytes with a one-year immutable cache header.

Then point the URL builder at the proxy instead of the API:

const urlFor = client.createImageUrlBuilder({ proxyPath: '/media' })
const url = urlFor(post.cover).medium().url()
// /media/media-8f3a1c/file?w=800

Note what changed: in proxy mode named variants are translated into width parameters — thumbnail to w=300, medium to w=800, large to w=1200 — and the URL points at /file. Since the proxy does not forward the query string to the API, and the API ignores it anyway, what you actually receive is the original file. Proxy mode gets you a same-origin URL and a hidden backend, not a resized image.

createNextMediaProxy, createExpressMediaProxy and the framework-agnostic createMediaProxy are in the same module.