This file is written for AI coding agents (Claude Code, Copilot, Cursor, etc.). Follow the instructions below precisely when upgrading a frontend project to serve media through MediaX.
MediaX is a self-hosted media proxy and processing server. It sits between a frontend and the underlying file storage (local disk, S3, GCS, HTTP CDN). When the frontend requests a media URL, MediaX fetches the original file from storage, applies on-the-fly transformations (resize, reformat, compress, transcode, thumbnail), and streams the result to the browser.
The frontend never talks to storage directly. Every media URL points at MediaX.
MediaX routes requests using the HTTP Host header, not the URL path alone.
When a request arrives, MediaX looks up the incoming hostname in its Origins table. Each Origin maps a domain to a Project, which has one or more Storage backends. If the hostname is not in the Origins table, MediaX returns 403 Forbidden.
Consequence for frontend integration: every media URL the frontend constructs must reach MediaX with a Host header that matches a configured Origin domain. The safest and most common way to achieve this is through a reverse-proxy rule that forwards a dedicated URL prefix to MediaX while overriding the Host header.
A frontend application has its own path base — the prefix under which it serves its own pages and API calls.
For example, a frontend running at https://app.example.com might serve:
/ → React/Vue/Angular app shell
/api/ → backend API proxy
/static/ → bundled assets
The media path base must be a completely separate prefix that does NOT overlap with any of the frontend's own routes.
Recommended convention: use /media/ as the dedicated media prefix.
/media/** → proxied to MediaX (separate domain header)
everything else → served by the frontend / backend as usual
If the frontend's path base is already /app (i.e. it is mounted at /app), the media prefix must still be outside of it, for example /media/. Never use a sub-path of the frontend path base for media.
The frontend must proxy its dedicated media prefix to MediaX, forwarding the correct Host header.
server {
listen 443 ssl;
server_name example.com;
# All other frontend routes
location / {
proxy_pass http://frontend:3000;
proxy_set_header Host $host;
}
# Media prefix → MediaX
location /media/ {
proxy_pass http://mediax:8080/media/;
proxy_set_header Host media.example.com; # must match the Origin "domain"
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_valid 200 1d;
}
}module.exports = {
async rewrites() {
return [
{
source: '/media/:path*',
destination: 'http://localhost:8080/media/:path*',
},
]
},
}Because a dev proxy rewrite does not change the Host header automatically, configure a MediaX Origin with domain: "localhost" or use a tool like nginx even in dev.
export default {
server: {
proxy: {
'/media': {
target: 'http://localhost:8080',
changeOrigin: true,
headers: { Host: 'media.example.com' },
},
},
},
}Every media URL follows this pattern:
https://<frontend-domain>/media/<path-to-file-in-storage>?<parameters>
The /media/ prefix is stripped by MediaX (via prefix_path) before the file is looked up in storage. So if a file is stored at images/hero.jpg in the storage backend, the frontend URL is:
/media/images/hero.jpg
All parameters are query-string parameters appended to the media URL.
| Parameter | Type | Description | Example |
|---|---|---|---|
width |
integer | Output width in pixels (snapped to nearest standard size) | ?width=800 |
height |
integer | Output height in pixels | ?height=600 |
size |
string | Shorthand WxH — sets both width and height |
?size=800x600 |
format |
string | Output format: jpg, png, gif, webp, avif. Always prefer webp — it is supported by all modern browsers and produces 25–35% smaller files than JPEG at the same visual quality. Only fall back to jpg/png when the browser or use-case explicitly requires it. |
?format=webp |
q |
integer | Quality 1–100 | ?q=85 |
crop |
string | Enable cropping (set to any non-empty value). Without this parameter, aspect ratio is preserved automatically | ?crop=center |
dir |
string | Crop anchor: center, top, bottom, left, right |
?dir=top |
download |
bool | Force Content-Disposition: attachment |
?download=true |
When both width and height are specified, cropping is applied automatically unless crop is omitted.
When only one dimension is given, the other scales proportionally.
Supported input formats: JPG, PNG, GIF, WebP, AVIF Supported output formats: JPG, PNG, GIF, WebP, AVIF
| Parameter | Type | Description | Example |
|---|---|---|---|
width |
integer | Output width | ?width=1280 |
height |
integer | Output height | ?height=720 |
format |
string | Output format: mp4, webm, avi, mov, mkv, flv, wmv, m4v, 3gp, ogv, jpg, png, webp, avif |
?format=webm |
q |
integer | Quality 1–100 | ?q=75 |
profile |
string | Named encoding profile (configured in admin) | ?profile=hd |
preview |
string | Stream a lower-resolution preview: 480p, 720p, 1080p, 4k, or WxH |
?preview=720p |
thumbnail |
string | Extract a still frame as an image (480p, 720p, 1080p, 4k, or WxH) |
?thumbnail=480p&format=jpg |
ss |
integer | Timestamp in seconds for thumbnail extraction | ?ss=30 |
download |
bool | Force download | ?download=true |
Supported input formats: MP4, WebM, AVI, MOV, MKV, FLV, WMV, M4V, 3GP, OGV Thumbnail output: JPG, PNG, WebP, AVIF
| Parameter | Type | Description | Example |
|---|---|---|---|
format |
string | Output format: mp3, wav, flac, aac, ogg, m4a, wma, opus; or jpg/png/webp/avif to extract album art |
?format=flac |
q |
integer | Quality 1–100 | ?q=100 |
detail |
bool | Return JSON metadata instead of audio data (title, artist, album, bitrate, …) | ?detail=true |
Supported input formats: MP3, WAV, FLAC, AAC, OGG, M4A, WMA, Opus
| Parameter | Type | Description | Example |
|---|---|---|---|
thumbnail |
string | Generate a thumbnail image at WxH resolution (e.g. 1200x1700) |
?thumbnail=1200x1700&format=jpg |
format |
string | Thumbnail format: jpg, png, webp, avif |
?format=webp |
q |
integer | Thumbnail quality 1–100 | ?q=90 |
Supported input formats: PDF, DOCX, XLSX, PPTX, DOC, XLS, PPT, ODT, ODS, ODP, TXT, RTF, CSV, EPUB, XML
When upgrading a frontend codebase, follow these steps in order.
Find where the project stores the base URL for media/CDN assets. It may be in:
- An environment variable (
VITE_CDN_URL,NEXT_PUBLIC_MEDIA_URL,VUE_APP_MEDIA_URL, etc.) - A config file (
config.ts,constants.ts,settings.js, etc.) - Hardcoded
<img src="https://cdn.example.com/...">tags scattered in components
Set the base to the frontend's own media prefix path:
// config.ts (or .env)
export const MEDIA_BASE = '/media' // always a root-relative path, never the CDN domain directlyThe frontend must NEVER send media requests directly to the storage bucket or CDN. All requests go through /media/.
Create a single utility function that all media URLs in the project flow through. Place it in a shared location (e.g., src/utils/media.ts or src/lib/media.js).
// src/utils/media.ts
const MEDIA_BASE = import.meta.env.VITE_MEDIA_BASE ?? '/media'
interface ImageOptions {
width?: number
height?: number
size?: string // "WxH" shorthand, overrides width/height
format?: 'jpg' | 'png' | 'gif' | 'webp' | 'avif' // default: 'webp' — best compression for modern browsers
quality?: number // 1–100
crop?: string // crop anchor: 'center' | 'top' | 'bottom' | 'left' | 'right'
download?: boolean
}
interface VideoOptions {
width?: number
height?: number
format?: 'mp4' | 'webm' | 'avi' | 'mov' | 'mkv' | 'flv' | 'wmv' | 'm4v' | '3gp' | 'ogv' | 'jpg' | 'png' | 'webp' | 'avif'
quality?: number
profile?: string
preview?: string // '480p' | '720p' | '1080p' | '4k' | 'WxH'
thumbnail?: string // '480p' | '720p' | '1080p' | '4k' | 'WxH'
ss?: number // timestamp in seconds
download?: boolean
}
interface AudioOptions {
format?: 'mp3' | 'wav' | 'flac' | 'aac' | 'ogg' | 'm4a' | 'wma' | 'opus' | 'jpg' | 'png' | 'webp' | 'avif'
quality?: number
detail?: boolean
}
interface DocumentOptions {
thumbnail?: string // 'WxH' e.g. '1200x1700'
format?: 'jpg' | 'png' | 'webp' | 'avif'
quality?: number
}
function buildParams(opts: Record<string, string | number | boolean | undefined>): string {
const p = new URLSearchParams()
for (const [k, v] of Object.entries(opts)) {
if (v !== undefined && v !== null && v !== '') {
p.set(k, String(v))
}
}
const s = p.toString()
return s ? '?' + s : ''
}
/** Constructs a proxied image URL with on-the-fly processing parameters.
* Defaults to webp format for best compression. Override only when necessary. */
export function imageUrl(path: string, opts: ImageOptions = {}): string {
const { width, height, size, format = 'webp', quality, crop, download } = opts
return (
MEDIA_BASE +
'/' +
path.replace(/^\//, '') +
buildParams({ width, height, size, format, q: quality, crop, download })
)
}
/** Constructs a proxied video URL. Use thumbnail+format to request a still image. */
export function videoUrl(path: string, opts: VideoOptions = {}): string {
const { width, height, format, quality, profile, preview, thumbnail, ss, download } = opts
return (
MEDIA_BASE +
'/' +
path.replace(/^\//, '') +
buildParams({ width, height, format, q: quality, profile, preview, thumbnail, ss, download })
)
}
/** Constructs a proxied audio URL. Use format=jpg/png/webp/avif to extract album art. */
export function audioUrl(path: string, opts: AudioOptions = {}): string {
const { format, quality, detail } = opts
return MEDIA_BASE + '/' + path.replace(/^\//, '') + buildParams({ format, q: quality, detail })
}
/** Constructs a proxied document URL. Always specify thumbnail+format to get an image rendition. */
export function documentUrl(path: string, opts: DocumentOptions = {}): string {
const { thumbnail, format, quality } = opts
return MEDIA_BASE + '/' + path.replace(/^\//, '') + buildParams({ thumbnail, format, q: quality })
}Search the codebase for all patterns that produce media URLs:
grep -r "cdn.example.com" src/
grep -r "storage.googleapis.com" src/
grep -r "s3.amazonaws.com" src/
grep -r 'src="http' src/
grep -r "https://.*\.(jpg|png|gif|webp|mp4|mp3|pdf)" src/
Replace each occurrence. Examples:
Before
<img src="https://cdn.example.com/photos/hero.jpg" width={1200} height={600} />After
import { imageUrl } from '@/utils/media'
<img src={imageUrl('photos/hero.jpg', { width: 1200, height: 600, format: 'webp', quality: 85 })} />Before (avatar thumbnail)
<img src={`https://cdn.example.com/avatars/${user.id}.jpg`} />After
<img src={imageUrl(`avatars/${user.id}.jpg`, { size: '96x96', crop: 'center', format: 'webp' })} />Before (video)
<video src="https://cdn.example.com/videos/intro.mp4" />After
<video src={videoUrl('videos/intro.mp4', { format: 'mp4' })} />
<img src={videoUrl('videos/intro.mp4', { thumbnail: '480p', format: 'jpg', ss: 5 })} />Before (document)
<a href="https://cdn.example.com/docs/report.pdf">Report</a>After
{/* Link serves the original PDF */}
<a href={documentUrl('docs/report.pdf')}>Report</a>
{/* Preview thumbnail */}
<img src={documentUrl('docs/report.pdf', { thumbnail: '800x1100', format: 'jpg' })} />For responsive images, call imageUrl multiple times with different widths:
function ResponsiveImage({ path, alt }: { path: string; alt: string }) {
return (
<img
src={imageUrl(path, { width: 800, format: 'webp', quality: 85 })}
srcSet={[
imageUrl(path, { width: 480, format: 'webp', quality: 85 }) + ' 480w',
imageUrl(path, { width: 800, format: 'webp', quality: 85 }) + ' 800w',
imageUrl(path, { width: 1200, format: 'webp', quality: 85 }) + ' 1200w',
].join(', ')}
sizes="(max-width: 600px) 480px, (max-width: 1024px) 800px, 1200px"
alt={alt}
/>
)
}Add the variable to all relevant environment files:
# .env.development
VITE_MEDIA_BASE=/media # frontend dev server proxies /media → MediaX
# .env.production
VITE_MEDIA_BASE=/media # nginx/CDN proxies /media → MediaX
# Next.js
NEXT_PUBLIC_MEDIA_BASE=/mediaNever put the raw MediaX server URL here — the /media prefix is always served from the same domain as the frontend, via the reverse proxy.
Add the X-Debug: 1 request header to any media request. MediaX will return detailed headers explaining what happened:
X-Trace-ID – unique request identifier
X-Debug-Host – resolved hostname
X-Debug-Extension – detected file extension
X-Debug-MediaType – matched media type config
X-Debug-Options – parsed processing parameters
X-Debug-Error – error detail (if any)
X-Debug-Storage-N-Type – storage type tried (index N)
X-Debug-Storage-N-BasePath – base path for that storage
X-Debug-Storage-N-Error – error from that storage attempt
Use this to diagnose 404s (wrong storage path), 403s (unregistered domain), or processing errors.
| Mistake | Correct approach |
|---|---|
Pointing MEDIA_BASE to the raw MediaX host (http://mediax:8080) |
Always go through the frontend's proxy at /media |
| Using the same path prefix for both frontend routes and media | Reserve /media/ exclusively for MediaX; keep it out of the frontend router |
Sending requests to storage.googleapis.com or S3 directly |
All media goes through MediaX |
Forgetting to set the Host header in the proxy rule |
MediaX returns 403 if the hostname doesn't match an Origin |
Using filepath.Join-style paths with backslashes in storage config (Windows) |
Always use forward slashes in DSN and config paths |
Not stripping the prefix in prefix_path (Origin config) |
The prefix_path must match the proxy location prefix exactly |