Pushduck
Pushduck// S3 uploads for any framework

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:

lib/upload.ts
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:

app/api/upload/route.ts
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:

lib/upload.ts
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

app/api/upload/route.ts
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

app/api/upload/route.ts
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.