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:
- Request an upload URL — Call the API to get a presigned URL and file ID
- Upload directly to storage — PUT the file to the presigned URL
- 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:
| Field | Description |
|---|---|
fileName | Original filename |
contentType | MIME type |
fileSize | Size in bytes |
width / height | Dimensions (images and videos) |
blurHash | Low-resolution placeholder for progressive loading |
dominantColor | Primary color hex value |
altText | Translatable alt text for accessibility |
caption | Translatable caption |
folder | Organizational folder |
tags | Searchable 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
- Go to the Media section
- Drag and drop files onto the upload area, or click to browse
- Files are uploaded and processed automatically
- 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.jpgUpload to a specific folder:
foir media upload ./images/hero-banner.jpg --folder bannersList files
foir media listFilter by folder or MIME type:
foir media list --folder banners --mime-type imageGet file details
foir media get file_abc123Update 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 usageDelete a file
foir media delete file_abc123Use --permanent for a hard delete that cannot be restored:
foir media delete file_abc123 --permanentRestore a deleted file
foir media restore file_abc123Via 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=eyJzIjoibWVkaXVtIiwiYyI6IjEwLDEwLDgwLDgwIn0The ?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" }| Field | Meaning | Format |
|---|---|---|
s | Size preset | thumbnail (150 px) / small (320) / medium (640) / large (1280) / xlarge (1920) |
c | Crop rectangle (per-usage) | x,y,width,height as 0–100 percentages |
f | Focal 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.