Path Configuration
Complete guide to organizing your uploads with hierarchical path structures and custom file organization
Custom Route Paths
Organize your uploads with hierarchical path structures that provide clean file organization, prevent conflicts, and enable scalable storage patterns.
Default Behavior: By default, files are uploaded with just the sanitized filename (e.g., photo.jpg). No automatic paths, prefixes, or organization are added. You must explicitly configure paths if you want file organization.
New in v2.0: The hierarchical path system allows global configuration to provide the foundation while route-level paths extend and nest within it - no more overrides that lose configuration!
How Hierarchical Paths Work
The path system works in layers that build upon each other:
Path Structure: {globalPrefix}/{routePrefix}/{globalGenerated}
- Global prefix:
uploads(foundation for all files) - Route prefix:
images,documents(category organization) - Global generated:
{userId}/{timestamp}/{randomId}/{filename}(file structure)
Default Behavior
Without any path configuration, files are uploaded with just the sanitized filename:
// No path configuration
const { s3 } = createUploadConfig()
.provider("aws", { /* config */ })
.build();
const router = s3.createRouter({
imageUpload: s3.image().maxFileSize("5MB"),
});Result: Files are stored as photo.jpg, document.pdf, etc. (just the filename)
Basic Path Configuration
Global Foundation
To organize files, explicitly configure the base structure that all uploads will use:
import { createUploadConfig } from "pushduck/server";
const { s3 } = createUploadConfig()
.provider("cloudflareR2",{
// ... provider config
})
.paths({
// Global prefix - foundation for ALL uploads
prefix: "uploads",
// Global structure - used when routes don't override
// Use whatever metadata structure fits your app
generateKey: (file, metadata) => {
// Example: Using organizationId from your metadata
const orgId = (metadata as any)?.organizationId || "default";
// Add timestamp if you want chronological organization
const timestamp = Date.now();
// Add random ID if you want to prevent filename conflicts
const randomId = Math.random().toString(36).substring(2, 8);
// Sanitize filename
const sanitizedName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
// Return ONLY the file path part (no prefix)
// You control what goes in the path - timestamps, random IDs, etc.
return `${orgId}/${timestamp}/${randomId}/${sanitizedName}`;
},
})
.build();Route-Level Extensions
Extend the global foundation with route-specific organization:
import { s3 } from "@/lib/upload";
const s3Router = s3.createRouter({
// Images: uploads/images/{orgId}/{timestamp}/{randomId}/photo.jpg
imageUpload: s3
.image()
.maxFileSize("5MB")
.paths({
prefix: "images", // Nests under global prefix
}),
// Documents: uploads/documents/{orgId}/{timestamp}/{randomId}/report.pdf
documentUpload: s3
.file()
.maxFileSize("10MB")
.paths({
prefix: "documents", // Nests under global prefix
}),
// General: uploads/{orgId}/{timestamp}/{randomId}/file.ext
generalUpload: s3
.file()
.maxFileSize("20MB")
// No .paths() - uses pure global configuration
});✨ Result: Clean, predictable paths that scale with your application. Global config provides consistency while routes add organization.
Advanced Path Patterns
Custom Route Generation
Override the default structure for specific use cases:
galleryUpload: s3
.image()
.maxFileSize("5MB")
.middleware(async ({ req }) => {
// Your metadata structure - could be organizationId, projectId, etc.
return { organizationId: await getOrgId(req) };
})
.paths({
generateKey: (ctx) => {
const { file, metadata, globalConfig } = ctx;
const globalPrefix = globalConfig.prefix || "uploads";
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const orgId = (metadata as any)?.organizationId || "default";
// Custom path: uploads/gallery/2024/06/org-123/photo.jpg
return `${globalPrefix}/gallery/${year}/${month}/${orgId}/${file.name}`;
},
})Result: uploads/gallery/2024/06/org-123/photo.jpg
productUpload: s3
.image()
.maxFileSize("8MB")
.paths({
generateKey: (ctx) => {
const { file, metadata, globalConfig } = ctx;
const globalPrefix = globalConfig.prefix || "uploads";
const category = metadata.category || "general";
const productId = metadata.productId || "unknown";
const timestamp = Date.now();
// Custom path: uploads/products/electronics/prod-123/1234567890/image.jpg
return `${globalPrefix}/products/${category}/${productId}/${timestamp}/${file.name}`;
},
})Result: uploads/products/electronics/prod-123/1234567890/image.jpg
projectAssets: s3
.image()
.maxFileSize("2MB")
.middleware(async ({ req }) => {
// Your metadata - could be projectId, teamId, workspaceId, etc.
return { projectId: await getProjectId(req) };
})
.paths({
generateKey: (ctx) => {
const { file, metadata, globalConfig } = ctx;
const globalPrefix = globalConfig.prefix || "uploads";
const projectId = (metadata as any)?.projectId || "default";
const fileType = file.type.split('/')[0]; // image, video, etc.
// Custom path: uploads/projects/proj-123/assets/image/logo.jpg
return `${globalPrefix}/projects/${projectId}/assets/${fileType}/${file.name}`;
},
})Result: uploads/projects/proj-123/assets/image/logo.jpg
Environment-Based Paths
Organize files by environment for clean separation:
const { s3 } = createUploadConfig()
.provider("cloudflareR2",{
// ... provider config
})
.paths({
// Environment-aware global prefix
prefix: process.env.NODE_ENV === "production" ? "prod" : "dev",
generateKey: (file, metadata) => {
// Use whatever identifier fits your app - organizationId, teamId, etc.
const orgId = (metadata as any)?.organizationId || "default";
// Add timestamp and random ID yourself if you want them
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 8);
return `${orgId}/${timestamp}/${randomId}/${file.name}`;
},
})
.build();Result Paths:
- Development:
dev/images/org123/1234567890/abc123/photo.jpg - Production:
prod/images/org123/1234567890/abc123/photo.jpg
Path Configuration API
Global Configuration
Prop
Type
.paths({
prefix: "uploads",
generateKey: (file, metadata) => {
// Return the file path structure (without prefix)
// Use whatever metadata structure fits your app
const identifier = (metadata as any)?.organizationId || "default";
return `${identifier}/${Date.now()}/${file.name}`;
}
})Route Configuration
Prop
Type
.paths({
prefix: "images", // Nested under global prefix
generateKey: (ctx) => {
const { file, metadata, globalConfig, routeName } = ctx;
// Full control with access to global configuration
return `${globalConfig.prefix}/custom/${file.name}`;
}
})PathContext Reference
When using custom generateKey functions at the route level, you receive a context object:
Prop
Type
Real-World Examples
E-commerce Platform
const s3Router = s3.createRouter({
// Product images: uploads/products/{category}/{productId}/images/photo.jpg
productImages: s3
.image()
.maxFileSize("8MB")
.formats(["jpeg", "png", "webp"])
.middleware(async ({ req }) => {
const { productId, category } = await getProductContext(req);
return { productId, category, userId: await getUserId(req) };
})
.paths({
generateKey: (ctx) => {
const { file, metadata, globalConfig } = ctx;
const { productId, category } = metadata;
return `${globalConfig.prefix}/products/${category}/${productId}/images/${file.name}`;
},
}),
// User avatars: uploads/users/{userId}/avatar/profile.jpg
// Note: This example uses userId, but you can use any metadata structure
userAvatar: s3
.image()
.maxFileSize("2MB")
.formats(["jpeg", "png", "webp"])
.middleware(async ({ req }) => {
// Your metadata structure - could be userId, accountId, memberId, etc.
const userId = await getUserId(req);
return { userId, type: "avatar" };
})
.paths({
generateKey: (ctx) => {
const { file, metadata, globalConfig } = ctx;
const userId = (metadata as any)?.userId;
return `${globalConfig.prefix}/users/${userId}/avatar/${file.name}`;
},
}),
// Order documents: uploads/orders/{orderId}/documents/{timestamp}/receipt.pdf
orderDocuments: s3
.file()
.maxFileSize("10MB")
.types(["application/pdf", "image/*"])
.middleware(async ({ req }) => {
const { orderId } = await getOrderContext(req);
return { orderId, uploadedAt: new Date().toISOString() };
})
.paths({
generateKey: (ctx) => {
const { file, metadata, globalConfig } = ctx;
const timestamp = Date.now();
return `${globalConfig.prefix}/orders/${metadata.orderId}/documents/${timestamp}/${file.name}`;
},
}),
});Content Management System
const s3Router = s3.createRouter({
// Media library: uploads/media/{year}/{month}/{type}/filename.ext
mediaLibrary: s3
.file()
.maxFileSize("50MB")
.middleware(async ({ req, file }) => {
const userId = await getUserId(req);
const mediaType = file.type.split('/')[0]; // image, video, audio
return { userId, mediaType };
})
.paths({
generateKey: (ctx) => {
const { file, metadata, globalConfig } = ctx;
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const randomId = Math.random().toString(36).substring(2, 8);
return `${globalConfig.prefix}/media/${year}/${month}/${metadata.mediaType}/${randomId}-${file.name}`;
},
}),
// Page assets: uploads/pages/{pageSlug}/assets/image.jpg
pageAssets: s3
.image()
.maxFileSize("10MB")
.middleware(async ({ req }) => {
const { pageSlug } = await getPageContext(req);
return { pageSlug, userId: await getUserId(req) };
})
.paths({
generateKey: (ctx) => {
const { file, metadata, globalConfig } = ctx;
return `${globalConfig.prefix}/pages/${metadata.pageSlug}/assets/${file.name}`;
},
}),
// Temp uploads: uploads/temp/{userId}/{sessionId}/file.ext
tempUploads: s3
.file()
.maxFileSize("20MB")
.middleware(async ({ req }) => {
const userId = await getUserId(req);
const sessionId = req.headers.get('x-session-id') || 'unknown';
return { userId, sessionId, temporary: true };
})
.paths({
prefix: "temp", // Simple prefix approach
}),
});Best Practices
Path Organization
Use Descriptive Prefixes
Make paths self-documenting
// ✅ Good
.paths({ prefix: "user-avatars" })
.paths({ prefix: "product-images" })
// ❌ Avoid
.paths({ prefix: "imgs" })
.paths({ prefix: "files" })Include Identifiers
Make files traceable
// ✅ Good - includes user and timestamp
return `${prefix}/users/${userId}/${timestamp}/${file.name}`;
// ❌ Avoid - no traceability
return `${prefix}/${file.name}`;Prevent Conflicts
Use unique identifiers when needed
// ✅ Good - add timestamp and random ID yourself if you want them
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 8);
const identifier = metadata.organizationId || "default";
return `${prefix}/${identifier}/${timestamp}/${randomId}/${file.name}`;
// ✅ Also valid - simple path if conflicts aren't a concern
return `${prefix}/${identifier}/${file.name}`;Performance Tips
Path Length Limits: Most S3-compatible services have a 1024-character limit for object keys. Keep your paths reasonable!
- Use short, meaningful prefixes instead of long descriptive names
- Avoid deep nesting beyond 5-6 levels for better performance
- Add timestamps yourself if you want chronological ordering (e.g.,
Date.now()) - Add random IDs yourself if you need to prevent filename conflicts (e.g.,
Math.random().toString(36).substring(2, 8)) - Sanitize filenames to prevent special character issues
Security Considerations
// ✅ Sanitize user input in paths
const sanitizePathSegment = (input: string) => {
return input.replace(/[^a-zA-Z0-9.-]/g, "_").substring(0, 50);
};
.paths({
generateKey: (ctx) => {
const { file, metadata, globalConfig } = ctx;
// Sanitize whatever identifier you use from metadata
const identifier = (metadata as any)?.organizationId || "default";
const safeIdentifier = sanitizePathSegment(identifier);
const safeFilename = sanitizePathSegment(file.name);
return `${globalConfig.prefix}/orgs/${safeIdentifier}/${Date.now()}/${safeFilename}`;
}
})Migration from Legacy Paths
Upgrading from v1.x? The old pathPrefix option still works but is deprecated. Use the new hierarchical system for better organization.
Before (Legacy)
// Old way - simple prefix only
export const { POST, GET } = s3Router.handlers;After (Hierarchical)
// New way - hierarchical configuration
const { s3 } = createUploadConfig()
.provider("cloudflareR2",{ /* config */ })
.paths({
prefix: "uploads",
generateKey: (file, metadata) => {
// Use whatever metadata structure fits your app
const identifier = (metadata as any)?.organizationId || "default";
return `${identifier}/${Date.now()}/${file.name}`;
}
})
.build();
const s3Router = s3.createRouter({
images: s3.image().paths({ prefix: "images" }),
documents: s3.file().paths({ prefix: "documents" })
});Benefits of upgrading:
- ✅ Better organization with route-specific prefixes
- ✅ No configuration loss - global settings are preserved
- ✅ More flexibility with custom generation functions
- ✅ Type safety with PathContext
- ✅ Scalable patterns for growing applications
🎉 You're ready! Your uploads now have clean, organized, and scalable path structures that will grow with your application.