Skip to content

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>
  );
}