Skip to Content
FeaturesMedia & Files

Media & Files

Foir includes a built-in media system for managing images, videos, and documents. Upload assets, apply on-the-fly image transformations, and serve optimized media through a CDN.

Overview

The media system supports images, videos, and general file uploads. Key features include:

  • Drag-and-drop uploads in the admin dashboard
  • CLI uploads for scripting and automation
  • Presigned URL uploads via the API for direct-to-storage transfers
  • On-the-fly image transformations (resize, reformat, adjust quality)
  • Video processing with thumbnail extraction and HLS streaming
  • Rich metadata including blur hash, dominant color, alt text, and captions
  • Folder organization and tagging for media library management

Upload Flow

Uploads use a two-step presigned URL flow that keeps large files off the GraphQL endpoint:

  1. Request an upload URL — Call the API to get a presigned URL and file ID
  2. Upload directly to storage — PUT the file to the presigned URL
  3. Confirm the upload — Tell the API the upload is complete; metadata extraction begins automatically

After confirmation, the file enters a processing pipeline (PENDING -> PROCESSING -> READY) where dimensions, blur hash, dominant color, and other metadata are extracted.

File Metadata

Every file tracks:

FieldDescription
fileNameOriginal filename
contentTypeMIME type
fileSizeSize in bytes
width / heightDimensions (images and videos)
blurHashLow-resolution placeholder for progressive loading
dominantColorPrimary color hex value
altTextTranslatable alt text for accessibility
captionTranslatable caption
folderOrganizational folder
tagsSearchable tags

Alt text and captions support per-locale translations. When resolving content, the correct translation is returned based on the requested locale.

In the Admin

Uploading files

  1. Go to the Media section
  2. Drag and drop files onto the upload area, or click to browse
  3. Files are uploaded and processed automatically
  4. Optionally set alt text, captions, folders, and tags

Managing the media library

  • Browse by folder or search by filename
  • Filter by file type (images, videos, documents)
  • Bulk-select files for tagging or moving to folders
  • View storage usage under Settings > Billing

Via the CLI

Upload a file

foir media upload ./images/hero-banner.jpg

Upload to a specific folder:

foir media upload ./images/hero-banner.jpg --folder banners

List files

foir media list

Filter by folder or MIME type:

foir media list --folder banners --mime-type image

Get file details

foir media get file_abc123

Update a file

foir media update file_abc123 --folder heroes --tags "marketing,homepage"

Update file metadata

foir media update-metadata file_abc123 --alt-text "A scenic mountain landscape"

Check storage usage

foir media usage

Delete a file

foir media delete file_abc123

Use --permanent for a hard delete that cannot be restored:

foir media delete file_abc123 --permanent

Restore a deleted file

foir media restore file_abc123

Via the API

Upload flow

Step 1: Create an upload

mutation { createFileUpload(input: { fileName: "hero-banner.jpg" contentType: "image/jpeg" fileSize: 2048576 }) { fileId uploadUrl uploadHeaders } }

Step 2: Upload the file

Upload directly to the presigned URL:

const { fileId, uploadUrl, uploadHeaders } = data.createFileUpload; await fetch(uploadUrl, { method: 'PUT', headers: { ...uploadHeaders, 'Content-Type': 'image/jpeg', }, body: fileBuffer, });

Step 3: Confirm the upload

mutation { confirmFileUpload(fileId: "file_abc123") { id fileName fileUrl contentType fileSize width height blurHash dominantColor status } }

Queries

Get a file

query { file(id: "file_abc123") { id fileName fileUrl contentType fileSize width height blurHash dominantColor altText caption status } }

List files

query { files(limit: 20, contentType: "image", folder: "banners") { items { id fileName fileUrl contentType fileSize blurHash } total hasMore } }

Check storage usage

query { fileStorageUsage { totalBytes fileCount } }

Mutations

Update file properties

mutation { updateFile(id: "file_abc123", input: { fileName: "updated-name.jpg" folder: "heroes" tags: ["marketing", "homepage"] }) { id fileName folder tags } }

Update translatable metadata

mutation { updateFileMetadata(id: "file_abc123", input: { altText: "A scenic mountain landscape at sunset" caption: "Photo taken in the Swiss Alps" locale: "en" }) { id altText caption } }

Delete a file

mutation { deleteFile(id: "file_abc123") { success message } }

Image Transformations

Image transforms are driven by an opaque token bound to each usage of an image. The platform emits a URL like:

https://cdn.foir.io/file_abc123?t=eyJzIjoibWVkaXVtIiwiYyI6IjEwLDEwLDgwLDgwIn0

The ?t= parameter is a base64url-encoded JSON object the platform produced when resolving the image. Decoded, it has up to three fields:

{ "s": "medium", "c": "10,10,80,80", "f": "0.5,0.4" }
FieldMeaningFormat
sSize presetthumbnail (150 px) / small (320) / medium (640) / large (1280) / xlarge (1920)
cCrop rectangle (per-usage)x,y,width,height as 0–100 percentages
fFocal point (used for smart crop fallback)x,y as 0–1 floats

Why a token, not free-form params

Earlier versions of the API accepted free-form ?width=800&height=600&fit=COVER&format=WEBP parameters. They no longer do — that grammar would let scrapers fan out arbitrary variants and burn through CDN-side image-resize bills. The token grammar is opaque enough that only URLs the platform emits will hit the transform pipeline; arbitrary client-side rewrites just return the original.

Per-usage crops and focal points

When the same image appears in multiple places (a hero on the homepage, a thumbnail on the listing, a portrait crop in a card), each usage can have its own crop and focal point baked into the URL. The platform stores these alongside the embed in the content field and writes a fresh token whenever the image is resolved.

You don’t construct these tokens by hand — the platform emits them. Frontend code passes the URL straight from the API response into an <img> tag.

Format negotiation

Output format follows the request’s Accept header. Browsers that send Accept: image/avif get AVIF; everyone else gets the best supported format. There’s no format parameter on the URL.

Responsive images example

Pull the urls straight from the API — each entry in srcSet is a separate resolution that the platform has already tokenised at a different size:

function ResponsiveImage({ image }: { image: ImageValue }) { return ( <picture> {image.sources.map((src) => ( <source key={src.media} srcSet={src.url} media={src.media} /> ))} <img src={image.url} alt={image.alt} width={image.width} height={image.height} loading="lazy" /> </picture> ); }

If you need a specific size for a custom layout, request it from the platform’s image-resolution helper rather than rewriting the URL — the platform will mint a token at the size you ask for.

Progressive loading with blur hash

Use the blurHash field to show a low-resolution placeholder while the full image loads:

import { Blurhash } from 'react-blurhash'; function ImageWithPlaceholder({ image }) { const [loaded, setLoaded] = useState(false); return ( <div style={{ position: 'relative' }}> {!loaded && image.blurHash && ( <Blurhash hash={image.blurHash} width={400} height={300} /> )} <img src={image.url} alt={image.alt} width={image.width} height={image.height} onLoad={() => setLoaded(true)} style={{ display: loaded ? 'block' : 'none' }} /> </div> ); }

Video Features

Uploaded videos are processed automatically:

  • Thumbnail extraction — A poster frame is generated for display before playback
  • Duration detection — Stored in file metadata
  • HLS streaming — Adaptive bitrate playback for smooth delivery (available on paid plans)

Best Practices

  • Always set alt text for images. It improves accessibility and SEO, and supports per-locale translations.
  • Use blur hash placeholders for a polished loading experience, especially on image-heavy pages.
  • Organize files into folders and use tags for easy discovery in the media library.
  • Set per-usage crops in the editor (drag the focal point or crop handles) rather than relying on a single source crop — the platform bakes the per-usage geometry into the URL token automatically.
  • Pass image URLs straight through from the API response. Don’t try to rewrite the ?t= token client-side; unknown tokens just return the original image.
Last updated on