Decoupling File Storage in Next.js
Hardcoding file uploads directly to third-party SDKs is a common trap in fullstack Next.js applications. When you couple API routes or server actions to Cloudinary, AWS S3, or Supabase Storage, local development turns sluggish, integration tests require mock gymnastics, and switching storage providers turns into a painful multi-file refactor.
The solution is straightforward: abstract storage behind a unified interface and resolve the active driver dynamically via environment variables.
The storage contract
Every storage driver only needs to satisfy a minimal contract: write, read, delete, check existence, and resolve public URLs.
export type DiskName = 'local' | 'cloudinary';
export interface PutOptions {
folder?: string;
contentType?: string;
}
export interface PutResult {
url: string;
key: string;
size: number;
}
export interface StorageDisk {
put(pathOrFilename: string, data: Buffer, options?: PutOptions): Promise<PutResult>;
get(pathOrUrl: string): Promise<Buffer | null>;
delete(pathOrUrl: string): Promise<boolean>;
exists(pathOrUrl: string): Promise<boolean>;
url(pathOrUrl: string): string;
}
By constraining the interface to standard Buffer payloads, business logic remains completely agnostic of the underlying transport protocol or vendor SDK.
Implementing the drivers
1. Local filesystem driver
For local development and unit tests, writing directly to public/uploads eliminates external dependencies and API rate limits:
import fs from 'node:fs/promises';
import path from 'node:path';
import type { StorageDisk, PutOptions, PutResult } from './types';
export class LocalDisk implements StorageDisk {
private rootDir: string;
private publicPrefix: string;
constructor() {
this.rootDir = path.join(process.cwd(), 'public', 'uploads');
this.publicPrefix = '/uploads';
}
async put(filename: string, data: Buffer, options?: PutOptions): Promise<PutResult> {
const folder = options?.folder ?? '';
const targetDir = path.join(this.rootDir, folder);
await fs.mkdir(targetDir, { recursive: true });
const targetPath = path.join(targetDir, filename);
await fs.writeFile(targetPath, data);
const relativeKey = path.join(folder, filename).replace(/\\/g, '/');
return {
url: `${this.publicPrefix}/${relativeKey}`,
key: relativeKey,
size: data.length,
};
}
async delete(key: string): Promise<boolean> {
try {
await fs.unlink(path.join(this.rootDir, key));
return true;
} catch {
return false;
}
}
// get, exists, and url implementation...
}
2. Cloud driver (Cloudinary)
The cloud driver wraps the vendor's Node.js SDK while returning the exact same normalized PutResult:
import { v2 as cloudinary } from 'cloudinary';
import type { StorageDisk, PutOptions, PutResult } from './types';
export class CloudinaryDisk implements StorageDisk {
async put(filename: string, data: Buffer, options?: PutOptions): Promise<PutResult> {
return new Promise((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream(
{
folder: options?.folder,
public_id: filename.replace(/\.[^/.]+$/, ''),
resource_type: 'auto',
},
(error, result) => {
if (error || !result) return reject(error);
resolve({
url: result.secure_url,
key: result.public_id,
size: result.bytes,
});
}
);
uploadStream.end(data);
});
}
async delete(publicId: string): Promise<boolean> {
const res = await cloudinary.uploader.destroy(publicId);
return res.result === 'ok';
}
}
The manager manager singleton
A unified facade instantiates drivers lazily and routes operations to the active configuration:
class StorageManager {
private disks = new Map<DiskName, StorageDisk>();
private defaultDiskName: DiskName;
constructor() {
this.defaultDiskName = (process.env.STORAGE_DRIVER as DiskName) || 'local';
}
disk(name?: DiskName): StorageDisk {
const target = name || this.defaultDiskName;
if (!this.disks.has(target)) {
if (target === 'cloudinary') {
this.disks.set('cloudinary', new CloudinaryDisk());
} else {
this.disks.set('local', new LocalDisk());
}
}
return this.disks.get(target)!;
}
async put(path: string, data: Buffer, options?: PutOptions) {
return this.disk().put(path, data, options);
}
async delete(path: string) {
return this.disk().delete(path);
}
}
export const storage = new StorageManager();
Clean consumer usage
In API routes or server actions, upload logic shrinks to a single predictable call:
import { storage } from '@/lib/storage';
export async function POST(req: Request) {
const formData = await req.formData();
const file = formData.get('file') as File;
const buffer = Buffer.from(await file.arrayBuffer());
const result = await storage.put(`${Date.now()}-${file.name}`, buffer, {
folder: 'articles',
});
return Response.json({ url: result.url });
}
The Takeaway: Switching environments is now a single line in
.env(STORAGE_DRIVER=localfor offline/CI,STORAGE_DRIVER=cloudinaryfor staging/production). Zero code edits required across your entire codebase.