skip to content

Building an Image uploader with Astro and Cloudflare R2

/ 8 min read

Building an Image uploader with Astro and Cloudflare R2

Building an image uploader with Astro, Cloudflare R2, and TypeScript is a powerful combination that leverages Astro’s SSR capabilities and R2’s S3 compatibility. The recommended approach for security and efficiency is to use a server endpoint (Astro API Route or Action) to generate a presigned URL and then upload the file directly from the frontend to R2 using that URL.

Let’s go ahead and build this.

Generating Cloudflare R2 Storage Keys

First, we need to get our R2 storage keys from Cloudflare.

  • Create an Cloudflare account with an R2 bucket created.
  • Generate R2 API Token with Object Read and Write permissions.
  • Add in your new Astro environment variables.

Create An Astro Project

Let’ create a Astro project with command below adding cloudflare as an adapter.

We will name our project

Terminal window
pnpm create astro@latest astro-r2-uploader
cd astro-r2-uploader
pnpm astro add cloudflare

We also need to add all generated environmnet variables into our project wich looks something like below:

Terminal window
CLOUDFLARE_ACCOUNT_ID="your_account_id"
R2_ACCESS_KEY_ID="your_access_key_id"
R2_SECRET_ACCESS_KEY="your_secret_access_key"
R2_BUCKET_NAME="your_bucket_name"
R2_PUBLIC_URL="https://your-public-access-domain.com"

Create API endpoint to handel file upload.

We will build an API which will be a file based routing inside our project path astro-r2-uploader/src/pages/api/*.

This will be a POST endpoint that takes the image and stores our image inside of R2 storage. We will also use uuid as a unique id generator for our images with its respective file extension. Let’s iinstall it with command below:

Terminal window
pnpm i uuid

We’ll also create the endpoint for it

import type { APIRoute } from 'astro';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { v4 as uuidv4 } from 'uuid';
const s3Client = new S3Client({
region: 'auto',
endpoint: `https://${import.meta.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: import.meta.env.R2_ACCESS_KEY_ID,
secretAccessKey: import.meta.env.R2_SECRET_ACCESS_KEY,
},
});
export const POST: APIRoute = async ({ request }) => {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return new Response(JSON.stringify({ error: 'No file provided' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const fileExtension = file.name.split('.').pop();
const fileName = `${uuidv4()}.${fileExtension}`;
const buffer = await file.arrayBuffer();
const uploadCommand = new PutObjectCommand({
Bucket: import.meta.env.R2_BUCKET_NAME,
Key: fileName,
Body: new Uint8Array(buffer),
ContentType: file.type,
});
await s3Client.send(uploadCommand);
const publicUrl = `${import.meta.env.R2_PUBLIC_URL}/${fileName}`;
return new Response(
JSON.stringify({
success: true,
fileName,
url: publicUrl,
size: file.size,
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
} catch (error) {
console.error('Upload error:', error);
return new Response(
JSON.stringify({ error: 'Upload failed', details: String(error) }),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
};

Building Our UI

We will be adding react into the mix so that we can handel reactive UI better. For this we will install react inside of our Astro project as an integration.

Terminal window
pnpm astro add react

The command will automatically modifies our astro config file and makes nedded changes. We will also make the use of astro SSR feature so that we can treat our application not only static also server rendered pages included.

Our final config file would look like below:

import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
import react from '@astrojs/react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
adapter: cloudflare(),
integrations: [react()],
vite: {
ssr: {
external: ['@aws-sdk/client-s3'],
},
plugins: [tailwindcss()]
},
output: 'server'
});

Adding React Hooks

Let’s define some interface needed for our application. For this we will create a folder inside of our root folder and add types needed for our application. This will make our application type safe and more maintainable.

export interface SignedUrlResponse {
uploadUrl: string;
fileUrl: string;
key: string;
}
export interface FileUpload {
file: File;
preview: string;
uploadProgress: number; // 0 to 100
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
url?: string;
}
export interface R2Config {
accountId: string;
accessKeyId: string;
secretAccessKey: string;
bucketName: string;
publicUrl: string;
}
export interface UploadedImage {
fileName: string;
url: string;
size: number;
uploadedAt: string;
}
export interface UploadResponse {
success: boolean;
fileName: string;
url: string;
size: number;
}

With this added we can import things needed inside our functions. We will create a react hook that will be handy in helping us to upload our files. We will also use useState to track state of our application and useCallback hook so that it returns a memoized version of a callback function.

Inside this hook we will use The Fetch API which provides a JavaScript interface for making HTTP requests and processing the responses.

import { useCallback, useState } from "react";
import type { UploadedImage, UploadResponse } from "@/types/index";
export const useImageUpload = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const uploadImage = useCallback(
async (file: File): Promise<UploadedImage | null> => {
setIsLoading(true);
setError(null);
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Upload failed');
}
// ✨ Type assertion added for response data
const data: UploadResponse = await response.json();
return {
fileName: data.fileName,
url: data.url,
size: data.size,
uploadedAt: new Date().toISOString(),
};
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
setError(errorMsg);
return null;
} finally {
setIsLoading(false);
}
},
[]
);
return { uploadImage, isLoading, error };
};

Creating Our Upload Component

We will make the use of the hook inside of our React component for the upload functionality. This will be helpful in future when we have multiple places to upload files with a presigned URL. Makes our code more clean and maintainable.

import React, { useCallback, useState } from 'react';
import { formatFileSize } from '@/utils';
import { useImageUpload } from '@/hooks/useImageUpload';
import type { UploadedImage } from '@/types';
export const ImageUploader: React.FC = () => {
const [images, setImages] = useState<UploadedImage[]>([]);
const [dragActive, setDragActive] = useState(false);
const { uploadImage, isLoading, error } = useImageUpload();
const handleFileChange = useCallback(async (files: FileList) => {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.type.startsWith("image/")) {
setImages((prev) => [
...prev,
{
fileName: file.name,
url: "",
size: 0,
uploadedAt: new Date().toISOString(),
},
]);
continue;
}
const uploadedImage = await uploadImage(file);
if (uploadedImage) {
setImages((prev) => [...prev, uploadedImage]);
}
}
}, [uploadImage]);
const handleDrag = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setDragActive(e.type === "dragenter" || e.type === "dragover");
}, []);
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files?.length) {
handleFileChange(e.dataTransfer.files);
}
},
[handleFileChange]
);
const removeImage = useCallback((fileName: string) => {
setImages((prev) => prev.filter((img) => img.fileName !== fileName));
}, []);
return (
<div className="w-full max-w-4xl mx-auto p-6">
<div
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
${dragActive ? "border-blue-500 bg-blue-50" : "border-gray-300 bg-gray-50 hover:border-gray-400"}
`}
>
<input
type="file"
multiple
accept="image/*"
onChange={(e) => e.target.files && handleFileChange(e.target.files)}
className="hidden"
id="file-input"
disabled={isLoading}
/>
<label htmlFor="file-input" className="cursor-pointer">
<p className="text-lg font-semibold text-gray-700 mb-1">
Drop images here or click to upload
</p>
<p className="text-sm text-gray-500">
Supported: PNG, JPG, GIF, WebP
</p>
</label>
</div>
{error && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{isLoading && (
<div className="mt-4 flex items-center justify-center text-gray-600">
<div className="h-8 w-8 border-4 border-blue-500 border-t-transparent animate-spin rounded-full"></div>
<span className="ml-3">Uploading...</span>
</div>
)}
{/* Images after upload */}
{images.length > 0 && (
<div className="mt-8">
<h2 className="text-2xl font-bold mb-4">
Uploaded Images ({images.length})
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.isArray(images).map((image) => (
<div
key={image.fileName}
className="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm hover:shadow-lg transition-shadow"
>
{image?.url ? (
<img
src={image.url}
alt={image.fileName}
className="w-full h-48 object-cover"
/>
) : (
<div className="w-full h-48 bg-gray-100 flex items-center justify-center text-gray-500">
Invalid image
</div>
)}
<div className="p-4">
<p className="font-mono text-sm text-gray-800 truncate mb-2">{image.fileName}</p>
<p className="text-xs text-gray-500 mb-3">
{formatFileSize(image.size)} •{" "}
{new Date(image.uploadedAt).toLocaleString()}
</p>
{image?.url && (
<a
href={image?.url}
target="_blank"
className="text-blue-600 hover:text-blue-800 text-sm mb-2 block"
>
View
</a>
)}
<button
onClick={() => removeImage(image.fileName)}
className="w-full px-3 py-2 bg-red-50 text-red-600 text-sm rounded hover:bg-red-100 transition"
>
Remove
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};

We also have added styles inside our component with help of tailwind. We will also add a handy utility funciton to formet our file size.

export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};

Using Component In Astro pages

Finally we can import our component inside our AStro pages and make it is as a client:load directive. This will load and hydrate the component JavaScript immediately on page load. Also note we also added prerender value to false as we do not want this to treat this as a static page rather a server rendered page.

---
export const prerender = false;
import {ImageUploader} from '@/components/ImageUploader';
import Layout from '@/layouts/Layout.astro';
---
<Layout>
<ImageUploader client:load />
</Layout>

With this much we can sucessfully uploadded images using Astro SSR capability and store it in our Cloudflare R2 storage with S3 compatiability.

Conclusion

The implemented architecture successfully leverages Astro’s Server-Side Rendering (SSR) capabilities alongside the dynamic interactivity of a React component to create a secure and efficient image uploader. This design separates concerns by ensuring all sensitive Cloudflare R2 credentials remain secure on the server within the Astro API Route where our Server logic stays.

Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I’ll do my best to get back to you.

You can find the complete source code in Github Link here.

Thank you!