Qwik
Edge-optimized file uploads with Qwik using Web Standards - no adapter needed!
🚧 Client-Side In Development: Qwik server-side integration is fully functional with Web Standards APIs. However, Qwik-specific client-side components and hooks are still in development. You can use the standard pushduck client APIs for now.
Using pushduck with Qwik
Qwik is a web framework focused on resumability and edge optimization. It uses Web Standards APIs and provides instant loading with minimal JavaScript. Since Qwik uses standard Request/Response objects, pushduck handlers work directly without any adapters!
Web Standards Native: Qwik server endpoints use Web Standard Request/Response objects, making pushduck integration straightforward with zero overhead and perfect for edge deployment.
Quick Setup
Install dependencies
npm install pushduckConfigure upload router
import { createUploadConfig } from 'pushduck/server';
const { s3, createS3Router } = createUploadConfig()
.provider("cloudflareR2",{
accessKeyId: import.meta.env.VITE_AWS_ACCESS_KEY_ID!,
secretAccessKey: import.meta.env.VITE_AWS_SECRET_ACCESS_KEY!,
region: 'auto',
endpoint: import.meta.env.VITE_AWS_ENDPOINT_URL!,
bucket: import.meta.env.VITE_S3_BUCKET_NAME!,
accountId: import.meta.env.VITE_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
import type { RequestHandler } from '@builder.io/qwik-city';
import { uploadRouter } from '~/lib/upload';
// Direct usage - no adapter needed!
export const onGet: RequestHandler = async ({ request }) => {
return uploadRouter.handler(request);
};
export const onPost: RequestHandler = async ({ request }) => {
return uploadRouter.handler(request);
};Basic Integration
Simple Upload Route
import type { RequestHandler } from '@builder.io/qwik-city';
import { uploadRouter } from '~/lib/upload';
// Method 1: Combined handler (recommended)
export const onRequest: RequestHandler = async ({ request }) => {
return uploadRouter.handler(request);
};
// Method 2: Separate handlers (if you need method-specific logic)
export const onGet: RequestHandler = async ({ request }) => {
return uploadRouter.handlers.GET(request);
};
export const onPost: RequestHandler = async ({ request }) => {
return uploadRouter.handlers.POST(request);
};With CORS Support
import type { RequestHandler } from '@builder.io/qwik-city';
import { uploadRouter } from '~/lib/upload';
export const onRequest: RequestHandler = async ({ request, headers }) => {
// Handle CORS preflight
if (request.method === 'OPTIONS') {
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type');
return new Response(null, { status: 200 });
}
const response = await uploadRouter.handler(request);
// Add CORS headers to actual response
headers.set('Access-Control-Allow-Origin', '*');
return response;
};Advanced Configuration
Authentication with Qwik
import { createUploadConfig } from 'pushduck/server';
const { s3, createS3Router } = createUploadConfig()
.provider("cloudflareR2",{
accessKeyId: import.meta.env.VITE_AWS_ACCESS_KEY_ID!,
secretAccessKey: import.meta.env.VITE_AWS_SECRET_ACCESS_KEY!,
region: 'auto',
endpoint: import.meta.env.VITE_AWS_ENDPOINT_URL!,
bucket: import.meta.env.VITE_S3_BUCKET_NAME!,
accountId: import.meta.env.VITE_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
return { id: 'user-123', username: 'demo-user' };
}Client-Side Usage
Qwik has no dedicated pushduck binding. It does not need one — pushduck/core
exposes the upload engine directly, and Qwik wires it up in a few lines.
The engine holds live state (abort controllers, in-flight transfers) so it is
not serializable. Create it inside useVisibleTask$ and wrap it in
noSerialize — the standard Qwik pattern for client-only objects.
pushduck/core has no framework dependency and no server code. It is the
same engine the React, Vue, Svelte, and Solid bindings use, so upload
behaviour is identical across all of them.
Upload Component
import {
$,
component$,
noSerialize,
useStore,
useVisibleTask$,
type NoSerialize,
} from "@builder.io/qwik";
import {
createUploadEngine,
type UploadEngine,
type UploadEngineState,
} from "pushduck/core";
export const FileUpload = component$(() => {
// Mirror of engine state — plain serializable data, safe in a Qwik store.
const state = useStore<UploadEngineState>({
files: [],
isUploading: false,
errors: [],
progress: 0,
uploadSpeed: 0,
eta: 0,
});
// The engine itself is not serializable, so it lives behind noSerialize.
const ref = useStore<{ engine?: NoSerialize<UploadEngine> }>({});
useVisibleTask$(({ cleanup }) => {
const engine = createUploadEngine({
endpoint: "/api/upload",
route: "imageUpload",
onError: (error) => console.error("Upload failed:", error),
});
ref.engine = noSerialize(engine);
// subscribe() returns its own unsubscribe — hand it straight to cleanup.
cleanup(
engine.subscribe(() => {
Object.assign(state, engine.getSnapshot());
})
);
});
const onSelect = $((event: Event) => {
const input = event.target as HTMLInputElement;
ref.engine?.upload([...(input.files ?? [])]);
});
return (
<div class="space-y-4">
<input
type="file"
multiple
accept="image/*"
disabled={state.isUploading}
onChange$={onSelect}
/>
{state.isUploading && <progress value={state.progress} max={100} />}
{state.files.map((file) => (
<div key={file.id} class="flex gap-2 items-center">
<span>{file.name}</span>
<span>
{file.status} ({file.progress}%)
</span>
{file.status === "uploading" && (
<button onClick$={() => ref.engine?.cancel(file.id)}>Cancel</button>
)}
{file.url && (
<a href={file.url} target="_blank" rel="noreferrer">
View
</a>
)}
</div>
))}
{state.errors.map((error) => (
<p key={error} class="text-red-600">
{error}
</p>
))}
</div>
);
});Reusable Hook
Extracting the wiring keeps components tidy and gives you one place to change the endpoint:
import {
noSerialize,
useStore,
useVisibleTask$,
type NoSerialize,
} from "@builder.io/qwik";
import {
createUploadEngine,
type UploadEngine,
type UploadEngineState,
} from "pushduck/core";
export function useUpload(route: string, endpoint = "/api/upload") {
const state = useStore<UploadEngineState>({
files: [],
isUploading: false,
errors: [],
progress: 0,
uploadSpeed: 0,
eta: 0,
});
const ref = useStore<{ engine?: NoSerialize<UploadEngine> }>({});
useVisibleTask$(({ cleanup }) => {
const engine = createUploadEngine({ endpoint, route });
ref.engine = noSerialize(engine);
cleanup(
engine.subscribe(() => Object.assign(state, engine.getSnapshot()))
);
});
return { state, ref };
}Passing Client Metadata
ref.engine?.upload([...(input.files ?? [])], {
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 Loader
import { component$ } from '@builder.io/qwik';
import type { DocumentHead } from '@builder.io/qwik-city';
import { routeLoader$ } from '@builder.io/qwik-city';
import { FileUpload } from '~/components/file-upload';
export const useFiles = routeLoader$(async (requestEvent) => {
const userId = 'current-user'; // Get from session/auth
// Fetch files from database
const files = await getFilesForUser(userId);
return {
files: files.map(file => ({
id: file.id,
name: file.name,
url: file.url,
size: file.size,
uploadedAt: file.createdAt,
})),
};
});
export default component$(() => {
const filesData = useFiles();
const 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];
};
return (
<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 />
</div>
<div>
<h2 class="text-2xl font-semibold mb-4">Uploaded Files</h2>
{filesData.value.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">
{filesData.value.files.map((file) => (
<div key={file.id} 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>
);
});
export const head: DocumentHead = {
title: 'My Files',
};
async function getFilesForUser(userId: string) {
// Implement your database query logic
return [];
}Deployment Options
import { defineConfig } from 'vite';
import { qwikVite } from '@builder.io/qwik/optimizer';
import { qwikCity } from '@builder.io/qwik-city/vite';
import { qwikCloudflarePages } from '@builder.io/qwik-city/adapters/cloudflare-pages/vite';
export default defineConfig(() => {
return {
plugins: [
qwikCity({
adapter: qwikCloudflarePages(),
}),
qwikVite(),
],
};
});import { defineConfig } from 'vite';
import { qwikVite } from '@builder.io/qwik/optimizer';
import { qwikCity } from '@builder.io/qwik-city/vite';
import { qwikVercel } from '@builder.io/qwik-city/adapters/vercel-edge/vite';
export default defineConfig(() => {
return {
plugins: [
qwikCity({
adapter: qwikVercel(),
}),
qwikVite(),
],
};
});import { defineConfig } from 'vite';
import { qwikVite } from '@builder.io/qwik/optimizer';
import { qwikCity } from '@builder.io/qwik-city/vite';
import { qwikNetlifyEdge } from '@builder.io/qwik-city/adapters/netlify-edge/vite';
export default defineConfig(() => {
return {
plugins: [
qwikCity({
adapter: qwikNetlifyEdge(),
}),
qwikVite(),
],
};
});import { defineConfig } from 'vite';
import { qwikVite } from '@builder.io/qwik/optimizer';
import { qwikCity } from '@builder.io/qwik-city/vite';
import { qwikDeno } from '@builder.io/qwik-city/adapters/deno/vite';
export default defineConfig(() => {
return {
plugins: [
qwikCity({
adapter: qwikDeno(),
}),
qwikVite(),
],
};
});Environment Variables
# AWS Configuration
VITE_AWS_REGION=us-east-1
VITE_AWS_ACCESS_KEY_ID=your_access_key
VITE_AWS_SECRET_ACCESS_KEY=your_secret_key
VITE_AWS_S3_BUCKET=your-bucket-name
# Qwik
VITE_PUBLIC_UPLOAD_ENDPOINT=http://localhost:5173/api/uploadPerformance Benefits
Resumability
Zero hydration - apps resume from server state
Web Standards
No adapter overhead - direct Request/Response usage
Edge Optimized
Perfect for edge deployment and CDNs
Instant Loading
O(1) JavaScript loading regardless of app size
Real-Time Upload Progress
import { component$, useSignal, $ } from '@builder.io/qwik';
export const AdvancedUpload = component$(() => {
const uploadProgress = useSignal(0);
const isUploading = useSignal(false);
const handleFileUpload = $(async (event: Event) => {
const target = event.target as HTMLInputElement;
const files = target.files;
if (!files || files.length === 0) return;
isUploading.value = true;
uploadProgress.value = 0;
try {
// Simulate upload progress
for (let i = 0; i <= 100; i += 10) {
uploadProgress.value = i;
await new Promise(resolve => setTimeout(resolve, 100));
}
alert('Upload completed!');
} catch (error) {
console.error('Upload failed:', error);
alert('Upload failed!');
} finally {
isUploading.value = false;
uploadProgress.value = 0;
}
});
return (
<div class="upload-container max-w-md mx-auto">
<input
type="file"
multiple
onChange$={handleFileUpload}
disabled={isUploading.value}
class="w-full p-3 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
/>
{isUploading.value && (
<div class="mt-4">
<div class="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
class="h-full bg-green-500 transition-all duration-300 ease-out"
style={{ width: `${uploadProgress.value}%` }}
/>
</div>
<p class="text-center mt-2 text-sm text-gray-600">
{uploadProgress.value}% uploaded
</p>
</div>
)}
</div>
);
});Qwik City Form Integration
import { component$ } from '@builder.io/qwik';
import type { DocumentHead } from '@builder.io/qwik-city';
import { routeAction$, Form, zod$, z } from '@builder.io/qwik-city';
import { FileUpload } from '~/components/file-upload';
export const useUploadAction = routeAction$(async (data, requestEvent) => {
// Handle form submission
// Files are already uploaded via pushduck, just save metadata
console.log('Form data:', data);
// Redirect to files page
throw requestEvent.redirect(302, '/files');
}, zod$({
title: z.string().min(1),
description: z.string().optional(),
}));
export default component$(() => {
const uploadAction = useUploadAction();
return (
<main class="max-w-2xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Upload Files</h1>
<Form action={uploadAction} class="space-y-6">
<div>
<label for="title" class="block text-sm font-medium mb-2">
Title
</label>
<input
type="text"
id="title"
name="title"
required
class="w-full p-3 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label for="description" class="block text-sm font-medium mb-2">
Description
</label>
<textarea
id="description"
name="description"
rows={4}
class="w-full p-3 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">
Files
</label>
<FileUpload />
</div>
<button
type="submit"
disabled={uploadAction.isRunning}
class="w-full bg-blue-500 text-white p-3 rounded-lg hover:bg-blue-600 disabled:opacity-50"
>
{uploadAction.isRunning ? "Uploading..." : "Upload Files"}
</button>
</Form>
</main>
);
});
export const head: DocumentHead = {
title: 'Upload Form',
};Troubleshooting
Common Issues
- Route not found: Ensure your route is
src/routes/api/upload/[...path]/index.ts - Build errors: Check that pushduck is properly installed and configured
- Environment variables: Use
import.meta.env.VITE_prefix for client-side variables - Resumability: Remember to use
$suffix for event handlers and functions
Debug Mode
Enable debug logging:
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 {};
});Qwik Configuration
import { defineConfig } from 'vite';
import { qwikVite } from '@builder.io/qwik/optimizer';
import { qwikCity } from '@builder.io/qwik-city/vite';
export default defineConfig(() => {
return {
plugins: [qwikCity(), qwikVite()],
preview: {
headers: {
'Cache-Control': 'public, max-age=600',
},
},
// Environment variables configuration
define: {
'import.meta.env.VITE_AWS_ACCESS_KEY_ID': JSON.stringify(process.env.VITE_AWS_ACCESS_KEY_ID),
}
};
});Qwik provides a modern approach to web development with pushduck, offering instant loading and resumability while maintaining full compatibility with Web Standards APIs for optimal edge performance.