S3 Router
Build type-safe upload routes with schema validation, middleware, and lifecycle hooks. Complete S3 router configuration guide for image uploads, file validation, and custom paths.
S3 Router Configuration
The S3 router provides a type-safe way to define upload endpoints with schema validation, middleware, and lifecycle hooks.
Basic Router Setup
// app/api/upload/route.ts
import { s3 } from '@/lib/upload'
const s3Router = s3.createRouter({
imageUpload: s3
.image()
.maxFileSize('5MB')
.formats(['jpeg', 'jpg', 'png', 'webp'])
.middleware(async ({ file, metadata }) => {
// Add authentication and user context
return {
...metadata,
userId: 'user-123',
uploadedAt: new Date().toISOString(),
}
}),
documentUpload: s3
.file()
.maxFileSize('10MB')
.types(['application/pdf', 'text/plain'])
.paths({
prefix: 'documents',
}),
})
// Export the handler
export const { GET, POST } = s3Router.handlers; Schema Builders
Image Schema
s3.image()
.maxFileSize('5MB')
.formats(['jpeg', 'jpg', 'png', 'webp', 'gif'])
.dimensions({ minWidth: 100, maxWidth: 2000 })
.quality(0.8) // JPEG qualityFile Schema
s3.file()
.maxFileSize('10MB')
.types(['application/pdf', 'text/plain', 'application/json'])
.extensions(['pdf', 'txt', 'json'])Object Schema (Multiple Files)
s3.object({
images: s3.image().maxFileSize('5MB').maxFiles(5),
documents: s3.file().maxFileSize('10MB').maxFiles(2),
thumbnail: s3.image().maxFileSize('1MB').maxFiles(1),
})Route Configuration
Middleware
Add authentication, validation, and metadata:
.middleware(async ({ file, metadata, req }) => {
// Authentication
const user = await authenticateUser(req)
if (!user) {
throw new Error('Authentication required')
}
// File validation
if (file.size > 10 * 1024 * 1024) {
throw new Error('File too large')
}
// Return enriched metadata
return {
...metadata, // Client-provided metadata (e.g., albumId, tags)
userId: user.id,
userRole: user.role,
uploadedAt: new Date().toISOString(),
ipAddress: req.headers.get('x-forwarded-for'),
}
})Client Metadata Support: The metadata parameter contains data sent from the client via uploadFiles(files, metadata). This allows passing UI context like album selections, tags, or form data. The middleware can then enrich this client metadata with server-side data like authenticated user information.
Example with Client Metadata:
// Client component
const { uploadFiles } = upload.imageUpload();
uploadFiles(files, {
albumId: 'vacation-2025',
tags: ['beach', 'sunset'],
visibility: 'private'
});
// Server middleware receives and validates
.middleware(async ({ req, metadata }) => {
const user = await authenticateUser(req);
// Validate client-provided albumId
if (metadata?.albumId) {
const album = await db.albums.findFirst({
where: {
id: metadata.albumId,
userId: user.id // Ensure user owns the album
}
});
if (!album) throw new Error('Album not found or access denied');
}
return {
// Client metadata (validated)
albumId: metadata?.albumId,
tags: metadata?.tags || [],
visibility: metadata?.visibility || 'private',
// Server metadata (trusted)
userId: user.id, // From auth, NOT from client
role: user.role, // From auth, NOT from client
uploadedAt: new Date().toISOString()
};
})Security Warning: Client metadata is UNTRUSTED user input. Always validate and never trust client-provided identity claims (userId, role, permissions, etc.). Extract identity from authenticated sessions on the server.
Path Configuration
Control where files are stored:
.paths({
// Simple prefix
prefix: 'user-uploads',
// Custom path generation
generateKey: (ctx) => {
const { file, metadata, routeName } = ctx
const userId = metadata.userId
const timestamp = Date.now()
return `${routeName}/${userId}/${timestamp}/${file.name}`
},
// Simple suffix
suffix: 'processed',
})Lifecycle Hooks
React to upload events:
.onUploadStart(async ({ file, metadata }) => {
console.log(`Starting upload: ${file.name}`)
// Log to analytics
await analytics.track('upload_started', {
userId: metadata.userId,
filename: file.name,
fileSize: file.size,
})
})
.onUploadComplete(async ({ file, url, metadata }) => {
console.log(`Upload complete: ${file.name} -> ${url}`)
// Save to database
await db.files.create({
filename: file.name,
url,
userId: metadata.userId,
size: file.size,
contentType: file.type,
uploadedAt: new Date(),
})
// Send notification
await notificationService.send({
userId: metadata.userId,
type: 'upload_complete',
message: `${file.name} uploaded successfully`,
})
})
.onUploadError(async ({ file, error, metadata }) => {
console.error(`Upload failed: ${file.name}`, error)
// Log error
await errorLogger.log({
operation: 'file_upload',
error: error.message,
userId: metadata.userId,
filename: file.name,
})
})Advanced Examples
E-commerce Product Images
const productRouter = s3.createRouter({
productImages: s3
.image()
.maxFileSize('5MB')
.formats(['jpeg', 'jpg', 'png', 'webp'])
.dimensions({ minWidth: 800, maxWidth: 2000 })
.middleware(async ({ metadata, req }) => {
const user = await authenticateUser(req)
const productId = metadata.productId
// Verify user owns the product
const product = await db.products.findFirst({
where: { id: productId, ownerId: user.id }
})
if (!product) {
throw new Error('Product not found or access denied')
}
return {
...metadata,
userId: user.id,
productId,
productName: product.name,
}
})
.paths({
generateKey: (ctx) => {
const { metadata } = ctx
return `products/${metadata.productId}/images/${Date.now()}.jpg`
}
})
.onUploadComplete(async ({ url, metadata }) => {
// Update product with new image
await db.products.update({
where: { id: metadata.productId },
data: {
images: {
push: url
}
}
})
}),
productDocuments: s3
.file()
.maxFileSize('10MB')
.types(['application/pdf'])
.paths({
prefix: 'product-docs',
})
.onUploadComplete(async ({ url, metadata }) => {
await db.productDocuments.create({
productId: metadata.productId,
documentUrl: url,
type: 'specification',
})
}),
})User Profile System
const profileRouter = s3.createRouter({
avatar: s3
.image()
.maxFileSize('2MB')
.formats(['jpeg', 'jpg', 'png'])
.dimensions({ minWidth: 100, maxWidth: 500 })
.middleware(async ({ req }) => {
const user = await authenticateUser(req)
return { userId: user.id, type: 'avatar' }
})
.paths({
generateKey: (ctx) => {
return `users/${ctx.metadata.userId}/avatar.jpg`
}
})
.onUploadComplete(async ({ url, metadata }) => {
// Update user profile
await db.users.update({
where: { id: metadata.userId },
data: { avatarUrl: url }
})
// Invalidate cache
await cache.del(`user:${metadata.userId}`)
}),
documents: s3
.object({
resume: s3.file().maxFileSize('5MB').types(['application/pdf']).maxFiles(1),
portfolio: s3.file().maxFileSize('10MB').maxFiles(3),
})
.middleware(async ({ req }) => {
const user = await authenticateUser(req)
return { userId: user.id }
})
.paths({
prefix: 'user-documents',
}),
})Client-Side Usage
Once you have your router set up, use it from the client:
// components/FileUploader.tsx
import { useUploadRoute } from 'pushduck'
export function FileUploader() {
const { upload, isUploading } = useUploadRoute('imageUpload')
const handleUpload = async (files: FileList) => {
try {
const results = await upload(files, {
// This metadata will be passed to middleware
productId: 'product-123',
category: 'main-images',
})
console.log('Upload complete:', results)
} catch (error) {
console.error('Upload failed:', error)
}
}
return (
<div>
<input
type="file"
multiple
accept="image/*"
onChange={(e) => e.target.files && handleUpload(e.target.files)}
disabled={isUploading}
/>
{isUploading && <p>Uploading...</p>}
</div>
)
}Type Safety
The router provides full TypeScript support:
// Types are automatically inferred
type RouterType = typeof s3Router
// Get route names
type RouteNames = keyof RouterType // 'imageUpload' | 'documentUpload'
// Get route input types
type ImageUploadInput = InferRouteInput<RouterType['imageUpload']>
// Get route metadata types
type ImageUploadMetadata = InferRouteMetadata<RouterType['imageUpload']>