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 blurhash, dimensions, and alt text
  • Folder organization for media library management

Upload Flow

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

  1. Request an upload — Call createFileUpload to get an upload ID and presigned upload URL
  2. Upload directly to storage — PUT the file to the presigned URL
  3. Confirm the upload — Call confirmFileUpload with the upload ID; metadata extraction begins automatically

After confirmation, the platform extracts dimensions, blurhash, and other metadata.

File Metadata

The public File type exposes:

FieldDescription
idFile identifier
filenameOriginal filename
mimeTypeMIME type
sizeSize in bytes
urlCDN URL
width / heightDimensions (images and videos)
blurhashLow-resolution placeholder for progressive loading
altAlt text for accessibility
createdAtUpload timestamp
deletedAtSoft-delete timestamp (null when active)

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

createFileUpload takes scalar arguments and returns a FileUploadResult with an uploadId and a presigned uploadUrl.

mutation { createFileUpload( filename: "hero-banner.jpg" mimeType: "image/jpeg" size: 2048576 folder: "banners" ) { uploadId uploadUrl } }

folder and metadata (JSON) are optional.

Step 2: Upload the file

PUT the file directly to the presigned URL:

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

Step 3: Confirm the upload

confirmFileUpload takes the uploadId and returns the finished File.

mutation { confirmFileUpload(uploadId: "upl_abc123") { id filename mimeType size url width height blurhash } }

Queries

Get a file

query { file(id: "file_abc123") { id filename mimeType size url width height blurhash alt createdAt } }

List files

files is a Relay cursor connection. Filter with folder, mimeType, and filename; paginate with first/after.

query { files(first: 20, mimeType: "image/", folder: "banners") { edges { node { id filename mimeType size url blurhash } cursor } pageInfo { hasNextPage endCursor } totalCount } }

Mutations

Delete a file

deleteFile soft-deletes the file and returns a boolean.

mutation { deleteFile(id: "file_abc123") }

Restore a soft-deleted file

mutation { restoreFile(id: "file_abc123") { id filename deletedAt } }

Permanently delete a file

mutation { permanentlyDeleteFile(id: "file_abc123") }

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.dev/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

An ImageValue exposes a variants object whose keys are the size presets (thumbnail, small, medium, large, xlarge). Each is a separate resolution the platform has already tokenised:

function ResponsiveImage({ image }: { image: ImageValue }) { return ( <img src={image.url} srcSet={[ `${image.variants.small} 320w`, `${image.variants.medium} 640w`, `${image.variants.large} 1280w`, `${image.variants.xlarge} 1920w`, ].join(', ')} alt={image.alt} width={image.width} height={image.height} loading="lazy" /> ); }

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 blurhash

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 blurhash 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