File Storage
Handle file uploads and serve stored files from your Juice app. Server actions receive standard FormData with File objects — the storage backend depends on your runtime.
Receiving File Uploads
A server action receives the uploaded file as a standard web File object from FormData. Validate size and type before storing.
async function uploadAvatar(formData: FormData) {
'use server';
const file = formData.get('avatar') as File;
if (!file || file.size === 0) {
return { error: 'No file selected' };
}
if (file.size > 5 * 1024 * 1024) {
return { error: 'File too large (max 5MB)' };
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return { error: 'Only JPEG, PNG, and WebP images are allowed' };
}
// Store the file (see platform-specific examples below)
}Storing Files
Where you write the file depends on your runtime and infrastructure. The upload action stays the same — only the storage call changes.
Write directly to the local filesystem with Bun.write.
await Bun.write(`./uploads/${file.name}`, file);Use the R2 binding from your Workers environment. Pass it via context using the pattern from the Cloudflare Workers guide.
const r2 = getContext(req, r2Key)!;
await r2.put(`avatars/${userId}/${file.name}`, file.stream(), {
httpMetadata: { contentType: file.type },
});The AWS SDK v3 uses fetch internally, so it works on every runtime — Bun, Workers, Deno, and Node.js.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: 'us-east-1' });
await s3.send(new PutObjectCommand({
Bucket: 'my-bucket',
Key: `avatars/${file.name}`,
Body: file.stream(),
ContentType: file.type,
}));Convert the file to bytes and write with Deno.writeFile.
const bytes = new Uint8Array(await file.arrayBuffer());
await Deno.writeFile(`./uploads/${file.name}`, bytes);Serving Files
Create a GET route that reads from your storage backend and returns a Response with the file data.
// app/routes/files/[name].ts
export async function GET(req: Request, { params }: { params: { name: string } }) {
const file = Bun.file(`./uploads/${params.name}`);
if (!(await file.exists())) {
return new Response('Not found', { status: 404 });
}
return new Response(file, {
headers: {
'Content-Type': file.type,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}// app/routes/files/[name].ts
import { getContext } from '@cmj/juice/runtime';
import { r2Key } from '../../../server.js';
export async function GET(req: Request, { params }: { params: { name: string } }) {
const r2 = getContext(req, r2Key)!;
const object = await r2.get(`avatars/${params.name}`);
if (!object) {
return new Response('Not found', { status: 404 });
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}// app/routes/files/[name].ts
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: 'us-east-1' });
export async function GET(req: Request, { params }: { params: { name: string } }) {
try {
const res = await s3.send(new GetObjectCommand({
Bucket: 'my-bucket',
Key: `avatars/${params.name}`,
}));
return new Response(res.Body as ReadableStream, {
headers: {
'Content-Type': res.ContentType ?? 'application/octet-stream',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch {
return new Response('Not found', { status: 404 });
}
}// app/routes/files/[name].ts
export async function GET(req: Request, { params }: { params: { name: string } }) {
try {
const bytes = await Deno.readFile(`./uploads/${params.name}`);
return new Response(bytes, {
headers: {
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch {
return new Response('Not found', { status: 404 });
}
}Upload Form
A standard HTML form with encType="multipart/form-data". This works with and without JavaScript — progressive enhancement out of the box.
// app/routes/profile.tsx
import React from 'react';
export default function Profile() {
return (
<form action={uploadAvatar} method="POST" encType="multipart/form-data">
<label>
Avatar
<input type="file" name="avatar" accept="image/*" />
</label>
<button type="submit">Upload</button>
</form>
);
}