Pushduck
Pushduck// S3 uploads for any framework

Astro

Modern static site file uploads with Astro using Web Standards - no adapter needed!

🚧 Client-Side In Development: Astro server-side integration is fully functional with Web Standards APIs. However, Astro-specific client-side components and hooks are still in development. You can use the standard pushduck client APIs for now.

Using pushduck with Astro

Astro is a modern web framework for building fast, content-focused websites with islands architecture. It uses Web Standards APIs and provides excellent performance with minimal JavaScript. Since Astro uses standard Request/Response objects, pushduck handlers work directly without any adapters!

Web Standards Native: Astro API routes use Web Standard Request/Response objects, making pushduck integration straightforward with zero overhead.

Quick Setup

Install dependencies

npm install pushduck

Configure upload router

src/lib/upload.ts
import { createUploadConfig } from 'pushduck/server';

const { s3, createS3Router } = createUploadConfig()
  .provider("cloudflareR2",{
    accessKeyId: import.meta.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: import.meta.env.AWS_SECRET_ACCESS_KEY!,
    region: 'auto',
    endpoint: import.meta.env.AWS_ENDPOINT_URL!,
    bucket: import.meta.env.S3_BUCKET_NAME!,
    accountId: import.meta.env.R2_ACCOUNT_ID!,
  })
  .build();

export const uploadRouter = createS3Router({
  imageUpload: s3.image().maxFileSize("5MB"),
  documentUpload: s3.file().maxFileSize("10MB")
});

export type AppUploadRouter = typeof uploadRouter;

Create API route

src/pages/api/upload/[...path].ts
import type { APIRoute } from 'astro';
import { uploadRouter } from '../../../lib/upload';

// Direct usage - no adapter needed!
export const ALL: APIRoute = async ({ request }) => {
  return uploadRouter.handler(request);
};

Basic Integration

Simple Upload Route

src/pages/api/upload/[...path].ts
import type { APIRoute } from 'astro';
import { uploadRouter } from '../../../lib/upload';

// Method 1: Combined handler (recommended)
export const ALL: APIRoute = async ({ request }) => {
  return uploadRouter.handler(request);
};

// Method 2: Separate handlers (if you need method-specific logic)
export const GET: APIRoute = async ({ request }) => {
  return uploadRouter.handlers.GET(request);
};

export const POST: APIRoute = async ({ request }) => {
  return uploadRouter.handlers.POST(request);
};

With CORS Support

src/pages/api/upload/[...path].ts
import type { APIRoute } from 'astro';
import { uploadRouter } from '../../../lib/upload';

export const ALL: APIRoute = async ({ request }) => {
  // Handle CORS preflight
  if (request.method === 'OPTIONS') {
    return new Response(null, {
      status: 200,
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type',
      },
    });
  }

  const response = await uploadRouter.handler(request);
  
  // Add CORS headers to actual response
  response.headers.set('Access-Control-Allow-Origin', '*');
  
  return response;
};

Advanced Configuration

Authentication with Astro

src/lib/upload.ts
import { createUploadConfig } from 'pushduck/server';

const { s3, createS3Router } = createUploadConfig()
  .provider("cloudflareR2",{
    accessKeyId: import.meta.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: import.meta.env.AWS_SECRET_ACCESS_KEY!,
    region: 'auto',
    endpoint: import.meta.env.AWS_ENDPOINT_URL!,
    bucket: import.meta.env.S3_BUCKET_NAME!,
    accountId: import.meta.env.R2_ACCOUNT_ID!,
  })
  .paths({
    prefix: 'uploads',
    generateKey: (file, metadata) => {
      return `${metadata.userId}/${Date.now()}/${file.name}`;
    }
  })
  .build();

export const uploadRouter = createS3Router({
  // Private uploads with cookie-based authentication
  privateUpload: s3
    .image()
    .maxFileSize("5MB")
    .middleware(async ({ req }) => {
      const cookies = req.headers.get('Cookie');
      const sessionId = parseCookie(cookies)?.sessionId;
      
      if (!sessionId) {
        throw new Error('Authentication required');
      }
      
      const user = await getUserFromSession(sessionId);
      if (!user) {
        throw new Error('Invalid session');
      }
      
      return {
        userId: user.id,
        username: user.username,
      };
    }),

  // Public uploads (no auth)
  publicUpload: s3
    .image()
    .maxFileSize("2MB")
    // No middleware = public access
});

export type AppUploadRouter = typeof uploadRouter;

// Helper functions
function parseCookie(cookieString: string | null) {
  if (!cookieString) return {};
  return Object.fromEntries(
    cookieString.split('; ').map(c => {
      const [key, ...v] = c.split('=');
      return [key, v.join('=')];
    })
  );
}

async function getUserFromSession(sessionId: string) {
  // Implement your session validation logic
  // This could connect to a database, Redis, etc.
  return { id: 'user-123', username: 'demo-user' };
}

Client-Side Usage

Astro renders static HTML by default, so uploads live in a client-side island. With a React island, use pushduck/client; the client:load directive hydrates it in the browser.

Using a Vue, Svelte, or Solid island instead? Import from pushduck/vue, pushduck/svelte, or pushduck/solid — the same upload engine, in each framework's idiom. For a framework-free island, use pushduck/core directly.

Upload Component

src/components/FileUpload.tsx
import { useUploadRoute } from "pushduck/client";
import type { AppUploadRouter } from "../lib/upload";

export function FileUpload() {
  const { files, progress, isUploading, errors, uploadFiles, cancel, reset } =
    useUploadRoute<AppUploadRouter>("imageUpload", {
      endpoint: "/api/upload",
      onSuccess: (uploaded) => console.log("Files uploaded:", uploaded),
      onError: (error) => console.error("Upload failed:", error),
    });

  return (
    <div className="space-y-4">
      <input
        type="file"
        multiple
        accept="image/*"
        disabled={isUploading}
        onChange={(e) => uploadFiles(Array.from(e.target.files ?? []))}
      />

      {isUploading && <progress value={progress} max={100} />}

      {files.map((file) => (
        <div key={file.id} className="flex gap-2 items-center">
          <span>{file.name}</span>
          <span>
            {file.status} ({file.progress}%)
          </span>
          {file.status === "uploading" && (
            <button onClick={() => cancel(file.id)}>Cancel</button>
          )}
          {file.url && (
            <a href={file.url} target="_blank" rel="noreferrer">
              View
            </a>
          )}
        </div>
      ))}

      {errors.map((error) => (
        <p key={error} className="text-red-600">
          {error}
        </p>
      ))}

      {files.length > 0 && <button onClick={reset}>Clear</button>}
    </div>
  );
}

Type-Safe Property Access

createUploadClient gives you one client with a property per route, fully typed from your server router:

src/lib/upload-client.ts
import { createUploadClient } from "pushduck/client";
import type { AppUploadRouter } from "../lib/upload";

export const upload = createUploadClient<AppUploadRouter>({
  endpoint: "/api/upload",
});

// In a component — route names autocomplete and their metadata is inferred:
//   const { uploadFiles, files } = upload.imageUpload();

Passing Client Metadata

Contextual data from your UI travels with the upload and is available in server middleware, lifecycle hooks, and path generation:

await uploadFiles(selectedFiles, {
  albumId: "vacation-2026",
  visibility: "private",
});

Client metadata is untrusted user input. Validate and sanitize it in your server middleware, and never trust client-provided identity claims such as userId or role — derive those from the authenticated session instead.

File Management

Server-Side File API

src/pages/api/files.ts
import type { APIRoute } from 'astro';

export const GET: APIRoute = async ({ request, url }) => {
  const searchParams = url.searchParams;
  const userId = searchParams.get('userId');
  
  if (!userId) {
    return new Response(JSON.stringify({ error: 'User ID required' }), {
      status: 400,
      headers: { 'Content-Type': 'application/json' }
    });
  }
  
  // Fetch files from database
  const files = await getFilesForUser(userId);
  
  return new Response(JSON.stringify({
    files: files.map(file => ({
      id: file.id,
      name: file.name,
      url: file.url,
      size: file.size,
      uploadedAt: file.createdAt,
    })),
  }), {
    headers: { 'Content-Type': 'application/json' }
  });
};

async function getFilesForUser(userId: string) {
  // Implement your database query logic
  return [];
}

File Management Page

src/pages/files.astro
---
// This runs on the server at build time or request time
const files = await fetch(`${Astro.url.origin}/api/files?userId=current-user`)
  .then(res => res.json())
  .catch(() => ({ files: [] }));
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width" />
    <title>My Files</title>
  </head>
  <body>
    <main class="container mx-auto px-4 py-8">
      <h1 class="text-3xl font-bold mb-8">My Files</h1>
      
      <div class="mb-8">
        <FileUpload client:load />
      </div>
      
      <div>
        <h2 class="text-2xl font-semibold mb-4">Uploaded Files</h2>
        
        {files.files.length === 0 ? (
          <p class="text-gray-500">No files uploaded yet.</p>
        ) : (
          <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
            {files.files.map((file: any) => (
              <div class="border rounded-lg p-4 hover:shadow-md transition-shadow">
                <h3 class="font-medium truncate" title={file.name}>
                  {file.name}
                </h3>
                <p class="text-sm text-gray-500">
                  {formatFileSize(file.size)}
                </p>
                <p class="text-sm text-gray-500">
                  {new Date(file.uploadedAt).toLocaleDateString()}
                </p>
                <a
                  href={file.url}
                  target="_blank"
                  rel="noopener noreferrer"
                  class="text-blue-500 hover:underline text-sm mt-2 inline-block"
                >
                  View File
                </a>
              </div>
            ))}
          </div>
        )}
      </div>
    </main>
  </body>
</html>

<script>
  import FileUpload from '../components/FileUpload.tsx';
  
  function formatFileSize(bytes: number): string {
    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
    if (bytes === 0) return '0 Bytes';
    const i = Math.floor(Math.log(bytes) / Math.log(1024));
    return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
  }
</script>

Deployment Options

astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/serverless';

export default defineConfig({
  output: 'server',
  adapter: vercel({
    runtime: 'nodejs18.x',
  }),
});
astro.config.mjs
import { defineConfig } from 'astro/config';
import netlify from '@astrojs/netlify/functions';

export default defineConfig({
  output: 'server',
  adapter: netlify(),
});
astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  output: 'server',
  adapter: node({
    mode: 'standalone',
  }),
});
astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
  output: 'server',
  adapter: cloudflare(),
});

Environment Variables

.env
# AWS Configuration
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_S3_BUCKET=your-bucket-name

# Astro
PUBLIC_UPLOAD_ENDPOINT=http://localhost:3000/api/upload

Performance Benefits

Islands Architecture

Only hydrate interactive components, minimal JavaScript

Web Standards

No adapter overhead - direct Request/Response usage

Fast Builds

Optimized build process with content focus

Edge Ready

Works on edge runtimes and CDNs

Real-Time Upload Progress

src/components/AdvancedUpload.tsx
import { useState } from 'react';

export default function AdvancedUpload() {
  const [uploadProgress, setUploadProgress] = useState(0);
  const [isUploading, setIsUploading] = useState(false);

  async function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
    const files = event.target.files;
    
    if (!files || files.length === 0) return;
    
    setIsUploading(true);
    setUploadProgress(0);
    
    try {
      // Simulate upload progress
      for (let i = 0; i <= 100; i += 10) {
        setUploadProgress(i);
        await new Promise(resolve => setTimeout(resolve, 100));
      }
      
      alert('Upload completed!');
    } catch (error) {
      console.error('Upload failed:', error);
      alert('Upload failed!');
    } finally {
      setIsUploading(false);
      setUploadProgress(0);
    }
  }

  return (
    <div className="upload-container max-w-md mx-auto">
      <input
        type="file"
        multiple
        onChange={handleFileUpload}
        disabled={isUploading}
        className="w-full p-3 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
      />
      
      {isUploading && (
        <div className="mt-4">
          <div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
            <div 
              className="h-full bg-green-500 transition-all duration-300 ease-out"
              style={{ width: `${uploadProgress}%` }}
            />
          </div>
          <p className="text-center mt-2 text-sm text-gray-600">
            {uploadProgress}% uploaded
          </p>
        </div>
      )}
    </div>
  );
}

Troubleshooting

Common Issues

  1. Route not found: Ensure your route is src/pages/api/upload/[...path].ts
  2. Build errors: Check that pushduck is properly installed and configured
  3. Environment variables: Use import.meta.env instead of process.env
  4. Client components: Remember to add client:load directive for interactive components

Debug Mode

Enable debug logging:

src/lib/upload.ts
export const uploadRouter = createS3Router({
  // ... routes
}).middleware(async ({ req, file }) => {
  if (import.meta.env.DEV) {
    console.log("Upload request:", req.url);
    console.log("File:", file.name, file.size);
  }
  return {};
});

Astro Configuration

astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import vue from '@astrojs/vue';

export default defineConfig({
  integrations: [
    react(), // For React components
    vue(),   // For Vue components
  ],
  output: 'server', // Required for API routes
  vite: {
    define: {
      // Make environment variables available
      'import.meta.env.AWS_ACCESS_KEY_ID': JSON.stringify(process.env.AWS_ACCESS_KEY_ID),
    }
  }
});

Astro provides an excellent foundation for building fast, content-focused websites with pushduck, combining the power of islands architecture with Web Standards APIs for optimal performance and developer experience.