{product.name}
${product.price}
# 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.price}{product.name}