# XFiles — File Management as a Service > API-first file management. Upload files, serve via CDN, auto-generate image variants. XFiles is a self-hosted file management platform. You create projects, get API keys, and use the REST API to upload, manage, and serve files through CloudFront CDN. Image variants (resized WebP/JPEG/PNG/AVIF) are generated on demand by Lambda@Edge and cached at the edge. ## Quick Links - Docs: https://xfiles.dev/docs - Dashboard: https://xfiles.dev/dashboard --- # PART 1: REST API ## Authentication All `/api/v1/*` endpoints require a Bearer token: Authorization: Bearer xf_AbC12dEfGhIjKlMnOpQrStUvWxYz9012 Keys are created per-project in the dashboard. Format: `xf_` + 32 random characters (35 total). Only the SHA-256 hash is stored — the raw key is shown once at creation. ## Core Concepts - **Project**: A namespace with its own API key, storage quota, and files. - **File**: An uploaded object stored in S3, served through CloudFront CDN. - **Visibility**: `"public"` (permanent CDN URL) or `"private"` (signed CDN URL, 5 min expiry). - **Entity**: Optional polymorphic attachment (`type`, `id`, `role`, `position`) for linking files to your domain objects. - **Image Variants**: Opt-in resized copies at predefined widths (100, 300, 400, 600, 800, 1000, 1200px). Generated on first request, cached by CDN. Formats: webp, jpeg, png, avif. - **Variant Visibility**: Independent from file visibility. A private original can have public variants (e.g. watermarked thumbnails). ## Upload Flow (3 steps) 1. **Intent** — POST to get a presigned S3 upload URL (locks content-type, size, metadata) 2. **Upload** — PUT the file directly to S3 via the presigned URL 3. **Confirm** — POST to verify the file exists in S3 and mark it confirmed Files never pass through the application server. Uploads go directly to S3 via presigned URLs. Downloads are always served through CloudFront CDN. ## API Reference Base URL: https://xfiles.dev ### POST /api/v1/files/upload-intent Get a presigned upload URL. The URL cryptographically locks Content-Type, Content-Length, and all metadata so the client cannot tamper with anything declared at intent time. Request body: { "filename": "photo.jpg", // Required "contentType": "image/jpeg", // Required — must be whitelisted "size": 204800, // Required — bytes, max 100MB "visibility": "public", // Optional — "public" | "private" (default: "private") "ownerUserId": "user_123", // Optional — track who uploaded "entity": { // Optional — attach to a domain object "type": "product", // Required if entity provided "id": "prod_abc123", // Required if entity provided "role": "gallery", // Optional — avatar, logo, gallery, document, etc. "position": 0 // Optional — display order (0 = primary, default: 0) }, "transformations": { // Optional — image variant behavior "image": { "enabled": true, // Enable variant generation (default: false) "visibility": "public" // "public" | "private" (default: "private") } } } Response: { "fileId": "abc123def456", "uploadUrl": "https://s3.amazonaws.com/...?X-Amz-Signature=...", "key": "files/originals/public/proj_id/abc123def456/photo-k9x2m7.jpg", "expiresIn": 900 } ### PUT {uploadUrl} Upload the file directly to S3. Content-Type and Content-Length must match exactly. curl -X PUT "UPLOAD_URL" -H "Content-Type: image/jpeg" --data-binary @photo.jpg ### POST /api/v1/files/confirm Verify the file exists in S3 and mark it as confirmed. Request body: { "fileId": "abc123def456" } Response: { "file": { "id": "abc123def456", "filename": "photo-k9x2m7.jpg", "originalFilename": "photo.jpg", "contentType": "image/jpeg", "size": 204800, "visibility": "public", "uploadStatus": "confirmed", "ownerUserId": "user_123", "entity": { "type": "product", "id": "prod_abc123", "role": "gallery", "position": 0 }, "transformations": { "image": { "enabled": true, "visibility": "public" } }, "createdAt": "2026-03-12T10:00:00.000Z" } } ### GET /api/v1/files List all confirmed files. No filters required — call with just your API key to get all files. Always returns 20 files per page (hardcoded, not configurable). Every file includes its entity attachment data (or null). All filters are optional and can be combined. When filtering by entity, results are sorted by position ascending (position 0 first). Optional query params: entityType, entityId, role, ownerUserId, visibility, variantVisibility, page (default 1) Examples: GET /api/v1/files — all files, page 1 GET /api/v1/files?page=3 — all files, page 3 GET /api/v1/files?entityType=product&entityId=prod_abc — files for a product GET /api/v1/files?visibility=public&ownerUserId=user_123 — public files by owner Response: { "files": [ { "id": "abc123", "filename": "sneaker-front-k9x2m7.jpg", "originalFilename": "sneaker-front.jpg", "contentType": "image/jpeg", "size": 204800, "visibility": "public", "uploadStatus": "confirmed", "ownerUserId": "user_123", "entity": { "type": "product", "id": "prod_abc", "role": "gallery", "position": 0 }, "transformations": { "image": { "enabled": true, "visibility": "public" } }, "createdAt": "2026-03-12T10:00:00.000Z" } ], "total": 42, "page": 1, "totalPages": 3 } ### GET /api/v1/files/{id} Get file details + CDN URLs. Public files get permanent URLs, private get signed URLs. Raster images with transforms enabled include variant URLs at all predefined widths. Response: { "file": { ... }, "url": "https://cdn.example.com/files/originals/public/.../photo.jpg", "variants": [ { "width": 100, "url": "https://cdn.example.com/files/variants/public/.../photo_w100.webp" }, { "width": 300, "url": "..." }, { "width": 400, "url": "..." }, { "width": 600, "url": "..." }, { "width": 800, "url": "..." }, { "width": 1000, "url": "..." }, { "width": 1200, "url": "..." } ] } ### PATCH /api/v1/files/{id} Update visibility, variant settings, owner, or position. Changing visibility moves the S3 object between public/private paths. Changing variant visibility purges existing variants. Request body (all optional): { "visibility": "public", "ownerUserId": "user_456", "position": 2, "transformations": { "image": { "enabled": true, "visibility": "public" } } } ### DELETE /api/v1/files/{id} Delete a single file — original, all variants, database record, and storage usage. Response: { "success": true } ### DELETE /api/v1/files Bulk delete by entity filters. At least one filter required. Query params: entityType, entityId, role Response: { "deleted": 5 } ### POST /api/v1/files/{id}/url Generate fresh CDN URLs. Public files return permanent CDN URLs, private files return signed URLs valid for 5 minutes. Response (public): { "url": "https://cdn.example.com/files/originals/public/.../photo.jpg", "variants": [{ "width": 100, "url": "..." }, ...] } Response (private): { "url": "https://cdn.example.com/...?Signature=...&Expires=...", "variants": [{ "width": 100, "url": "https://cdn.example.com/...?Signature=..." }, ...], "expiresIn": 300 } ## Upload Security The presigned URL cryptographically locks: - Content-Type — must match the declared type - Content-Length — must match the declared size - Visibility — cannot be tampered with - Project ID — cannot assign to a different project - File ID — cannot reassign to a different record - Expiry — URL valid for 15 minutes only After upload, the confirm endpoint calls HeadObject on S3 to verify the file actually exists before marking it confirmed. Stale pending records are cleaned up by a cron job. ## Allowed Content Types Images: image/jpeg, image/png, image/gif, image/webp, image/svg+xml, image/avif, image/tiff, image/bmp, image/ico Documents: application/pdf, application/msword, application/vnd.openxmlformats-*, text/plain, text/csv, text/html, text/css, text/javascript, application/json, application/xml Archives: application/zip, application/gzip, application/x-tar Audio: audio/mpeg, audio/wav, audio/ogg, audio/webm Video: video/mp4, video/webm, video/ogg Fonts: font/woff, font/woff2, font/ttf, font/otf Max file size: 100MB ## Filename Sanitization Input filenames are sanitized: lowercase, special chars removed, spaces → hyphens, 6-char unique suffix appended, base name capped at 100 chars. Original filename preserved in DB. Example: "Best Ever Photo (2024).JPG" → "best-ever-photo-2024-a7x9k2.jpg" ## S3 Path Structure files/originals/{public|private}/{projectId}/{fileId}/{sanitizedFilename} files/variants/{public|private}/{projectId}/{fileId}/{baseName}_w{width}.{format} ## Error Codes - 400 — Invalid request (missing fields, invalid content type, etc.) - 401 — Missing or invalid API key - 404 — File not found - 413 — Storage quota exceeded - 500 — Server error --- # PART 2: Next.js Integration ## Setup Add your API key to .env.local (server-side only — never expose to browser): XFILES_API_KEY=xf_AbC12dEfGhIjKlMnOpQrStUvWxYz9012 NEXT_PUBLIC_XFILES_URL=https://xfiles.dev Create a base fetch helper: // lib/xfiles.ts const API_KEY = process.env.XFILES_API_KEY!; const BASE_URL = process.env.NEXT_PUBLIC_XFILES_URL!; export async function xfiles(path: string, options: RequestInit = {}) { const res = await fetch(`${BASE_URL}${path}`, { ...options, headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", ...options.headers, }, }); if (!res.ok) { const error = await res.json().catch(() => ({})); throw new Error(error.error || `XFiles API error: ${res.status}`); } return res.json(); } ## File Service One service file for all operations: // lib/file-service.ts import { xfiles } from "@/lib/xfiles"; // ── Upload ────────────────────────────────────────────── export async function uploadFile( file: File, entity: { type: string; id: string; role: string; position?: number }, opts?: { visibility?: "public" | "private"; transforms?: { enabled: boolean; visibility?: "public" | "private" }; }, ) { const intent = await xfiles("/api/v1/files/upload-intent", { method: "POST", body: JSON.stringify({ filename: file.name, contentType: file.type, size: file.size, visibility: opts?.visibility ?? "private", entity, ...(opts?.transforms && { transformations: { image: opts.transforms }, }), }), }); await fetch(intent.uploadUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file, }); const { file: confirmed } = await xfiles("/api/v1/files/confirm", { method: "POST", body: JSON.stringify({ fileId: intent.fileId }), }); return confirmed; } // ── Get single file (with variant URLs) ───────────────── export async function getFile(fileId: string) { return xfiles(`/api/v1/files/${fileId}`); } // ── List by entity — sorted by position ───────────────── export async function listFiles(opts: { entityType?: string; entityId?: string; role?: string; limit?: number; }) { const params = new URLSearchParams(); if (opts.entityType) params.set("entityType", opts.entityType); if (opts.entityId) params.set("entityId", opts.entityId); if (opts.role) params.set("role", opts.role); if (opts.limit) params.set("limit", String(opts.limit)); const query = params.toString(); return xfiles(`/api/v1/files${query ? `?${query}` : ""}`); } // ── Update position ───────────────────────────────────── export async function updateFilePosition(fileId: string, position: number) { return xfiles(`/api/v1/files/${fileId}`, { method: "PATCH", body: JSON.stringify({ position }), }); } // ── Delete single file ────────────────────────────────── export async function deleteFile(fileId: string) { return xfiles(`/api/v1/files/${fileId}`, { method: "DELETE" }); } // ── Delete all files by entity ────────────────────────── export async function deleteByEntity(entityType: string, entityId: string) { const params = new URLSearchParams({ entityType, entityId }); return xfiles(`/api/v1/files?${params}`, { method: "DELETE" }); } ## Real-World Example: E-Commerce Product Gallery Entity mapping: type "product", id product.id, role "gallery", position 0/1/2... ### Create product with images // app/api/products/route.ts import { NextResponse } from "next/server"; import { db } from "@/lib/db"; import { products } from "@/lib/db/schema"; import { uploadFile, listFiles, getFile } from "@/lib/file-service"; import { desc } from "drizzle-orm"; export async function POST(req: Request) { const formData = await req.formData(); const name = formData.get("name") as string; const price = Number(formData.get("price")); const images = formData.getAll("images") as File[]; const [product] = await db.insert(products).values({ name, price }).returning(); const uploaded = await Promise.all( images.map((file, i) => uploadFile( file, { type: "product", id: product.id, role: "gallery", position: i }, { visibility: "public", transforms: { enabled: true, visibility: "public" } }, ), ), ); return NextResponse.json({ product, images: uploaded }); } ### List products with thumbnails export async function GET() { const allProducts = await db.select().from(products).orderBy(desc(products.createdAt)); const result = await Promise.all( allProducts.map(async (product) => { const { files } = await listFiles({ entityType: "product", entityId: product.id, role: "gallery", limit: 1, }); const thumb = files[0] ?? null; let thumbnailUrl = null; if (thumb) { const { variants } = await getFile(thumb.id); thumbnailUrl = variants?.find((v: any) => v.width === 400)?.url ?? null; } return { ...product, thumbnailUrl }; }), ); return NextResponse.json({ products: result }); } ### Get, update, and delete product // app/api/products/[id]/route.ts import { NextResponse } from "next/server"; import { db } from "@/lib/db"; import { products } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import { listFiles, getFile, uploadFile, deleteByEntity, updateFilePosition } from "@/lib/file-service"; type Params = { params: Promise<{ id: string }> }; // GET — product with all gallery images sorted by position export async function GET(_req: Request, { params }: Params) { const { id } = await params; const [product] = await db.select().from(products).where(eq(products.id, id)); if (!product) return NextResponse.json({ error: "Not found" }, { status: 404 }); const { files } = await listFiles({ entityType: "product", entityId: id, role: "gallery" }); const images = await Promise.all( files.map(async (f: any) => { const { variants } = await getFile(f.id); return { ...f, variants }; }), ); return NextResponse.json({ product, images }); } // DELETE — delete product and all its images in one call export async function DELETE(_req: Request, { params }: Params) { const { id } = await params; await deleteByEntity("product", id); await db.delete(products).where(eq(products.id, id)); return NextResponse.json({ success: true }); } ### Display components // components/ProductCard.tsx — thumbnail from 400w variant import Image from "next/image"; export function ProductCard({ product }: { product: any }) { return (
{product.thumbnailUrl ? ( {product.name} ) : (
No image
)}

{product.name}

${product.price}

); } // components/ProductGallery.tsx — full gallery with variant selection "use client"; import Image from "next/image"; import { useState } from "react"; export function ProductGallery({ images }: { images: any[] }) { const [selected, setSelected] = useState(0); const current = images[selected]; if (!images.length) return null; function getImageUrl(img: any, targetWidth: number) { const WIDTHS = [100, 300, 400, 600, 800, 1000, 1200]; const w = WIDTHS.find((v) => v >= targetWidth) ?? 1200; return img.variants?.find((v: any) => v.width === w)?.url; } return (
{current.originalFilename}
{images.map((img, i) => ( ))}
); } ## Tips - Always display raster images via variants, not the original URL. Variants are CDN-cached and permanent (for public files). - SVGs are vector graphics — they don't get variants and are served directly as originals. - Use `unoptimized` on Next.js Image components since images are already optimized by XFiles. - When listing files by entity, results are sorted by position ascending — position 0 is the primary image. - Use deleteByEntity when deleting a parent object to clean up all attached files in one call.