How to Upload Files from React and Next.js Using an API

April 3, 2026 · 12 min read

File uploads in React applications look simple on the surface: render a file input, grab the selected file, send it somewhere. In practice, you need to handle loading states, error feedback, progress indicators, file size validation, and the question of where the file actually goes. This guide walks through every piece of building a production-ready file upload in React and Next.js, using FilePost as the hosting API.

By the end, you will have a working upload component with error handling, a drag-and-drop variant, a Next.js API route that keeps your API key secret, and patterns you can adapt for any file upload API.

Prerequisites

curl -X POST https://filepost.dev/v1/signup \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com"}'

Basic React Upload Component

Let's start with the simplest possible implementation: a file input and a function that uploads the selected file to FilePost. This example uses the browser's built-in fetch API, so there are no additional dependencies.

import { useState } from "react";

function FileUploader() {
    const [url, setUrl] = useState(null);
    const [error, setError] = useState(null);
    const [loading, setLoading] = useState(false);

    async function handleUpload(event) {
        const file = event.target.files[0];
        if (!file) return;

        setLoading(true);
        setError(null);
        setUrl(null);

        const formData = new FormData();
        formData.append("file", file);

        try {
            const response = await fetch("https://filepost.dev/v1/upload", {
                method: "POST",
                headers: { "X-API-Key": "your_api_key_here" },
                body: formData,
            });

            if (!response.ok) {
                const msg = await response.text();
                throw new Error(`Upload failed (${response.status}): ${msg}`);
            }

            const data = await response.json();
            setUrl(data.url);
        } catch (err) {
            setError(err.message);
        } finally {
            setLoading(false);
        }
    }

    return (
        <div>
            <input type="file" onChange={handleUpload} disabled={loading} />
            {loading && <p>Uploading...</p>}
            {error && <p style={{ color: "red" }}>{error}</p>}
            {url && (
                <p>
                    Uploaded: <a href={url} target="_blank" rel="noopener">{url}</a>
                </p>
            )}
        </div>
    );
}

export default FileUploader;

This component covers the core flow: select a file, upload it, display the result or an error. The loading state disables the input during upload to prevent double submissions.

Important note about API keys in the browser

The example above includes the API key directly in the client-side code. This is fine for personal projects and prototypes, but for production applications, you should proxy uploads through your own backend to keep the API key secret. We will cover this with a Next.js API route later in the guide.

Adding File Validation

Before sending a file to the server, validate it on the client side. This saves bandwidth and gives users instant feedback instead of waiting for a server rejection.

const MAX_SIZE_MB = 50; // Free tier limit
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "application/pdf"];

function validateFile(file) {
    if (file.size > MAX_SIZE_MB * 1024 * 1024) {
        return `File is too large. Maximum size is ${MAX_SIZE_MB}MB.`;
    }
    if (ALLOWED_TYPES.length > 0 && !ALLOWED_TYPES.includes(file.type)) {
        return `File type "${file.type}" is not allowed.`;
    }
    return null;
}

// Inside your handleUpload function:
async function handleUpload(event) {
    const file = event.target.files[0];
    if (!file) return;

    const validationError = validateFile(file);
    if (validationError) {
        setError(validationError);
        return;
    }

    // ... proceed with upload
}

Note that FilePost accepts any file type, so the ALLOWED_TYPES check is optional and depends on your application's requirements. The file size validation is more important: it prevents uploading a 500MB file only to have the server reject it.

Upload with Progress Indicator

The fetch API does not natively support upload progress tracking. If you need a progress bar, use XMLHttpRequest or a library like Axios. Here is the Axios approach:

import { useState } from "react";
import axios from "axios";

function FileUploaderWithProgress() {
    const [progress, setProgress] = useState(0);
    const [url, setUrl] = useState(null);
    const [error, setError] = useState(null);
    const [loading, setLoading] = useState(false);

    async function handleUpload(event) {
        const file = event.target.files[0];
        if (!file) return;

        setLoading(true);
        setError(null);
        setUrl(null);
        setProgress(0);

        const formData = new FormData();
        formData.append("file", file);

        try {
            const response = await axios.post(
                "https://filepost.dev/v1/upload",
                formData,
                {
                    headers: { "X-API-Key": "your_api_key_here" },
                    onUploadProgress: (event) => {
                        const pct = Math.round((event.loaded / event.total) * 100);
                        setProgress(pct);
                    },
                }
            );

            setUrl(response.data.url);
        } catch (err) {
            setError(err.response?.data?.error || err.message);
        } finally {
            setLoading(false);
        }
    }

    return (
        <div>
            <input type="file" onChange={handleUpload} disabled={loading} />
            {loading && (
                <div style={{ marginTop: 8 }}>
                    <progress value={progress} max={100} />
                    <span> {progress}%</span>
                </div>
            )}
            {error && <p style={{ color: "red" }}>{error}</p>}
            {url && (
                <p>
                    Uploaded: <a href={url} target="_blank" rel="noopener">{url}</a>
                </p>
            )}
        </div>
    );
}

export default FileUploaderWithProgress;

The onUploadProgress callback fires as the browser sends data, giving you a smooth progress bar even for large files. This is particularly useful on the Starter plan (200MB max) or Pro plan (500MB max) where uploads can take several seconds.

Drag-and-Drop Upload Component

A drag-and-drop zone provides a better user experience than a plain file input. Here is a self-contained component that handles drag events, visual feedback, and the upload itself:

import { useState, useCallback } from "react";

function DropZoneUploader({ apiKey }) {
    const [isDragging, setIsDragging] = useState(false);
    const [url, setUrl] = useState(null);
    const [error, setError] = useState(null);
    const [loading, setLoading] = useState(false);

    const uploadFile = useCallback(async (file) => {
        setLoading(true);
        setError(null);
        setUrl(null);

        const formData = new FormData();
        formData.append("file", file);

        try {
            const response = await fetch("/api/upload", {
                method: "POST",
                body: formData,
            });

            if (!response.ok) throw new Error("Upload failed");

            const data = await response.json();
            setUrl(data.url);
        } catch (err) {
            setError(err.message);
        } finally {
            setLoading(false);
        }
    }, []);

    function handleDrop(event) {
        event.preventDefault();
        setIsDragging(false);
        const file = event.dataTransfer.files[0];
        if (file) uploadFile(file);
    }

    function handleDragOver(event) {
        event.preventDefault();
        setIsDragging(true);
    }

    function handleDragLeave() {
        setIsDragging(false);
    }

    const dropZoneStyle = {
        border: isDragging ? "2px solid #4f46e5" : "2px dashed #ccc",
        borderRadius: 8,
        padding: 40,
        textAlign: "center",
        background: isDragging ? "#eef2ff" : "#fafafa",
        cursor: "pointer",
        transition: "all 0.2s",
    };

    return (
        <div>
            <div
                style={dropZoneStyle}
                onDrop={handleDrop}
                onDragOver={handleDragOver}
                onDragLeave={handleDragLeave}
            >
                {loading ? "Uploading..." : "Drop a file here or click to select"}
                <input
                    type="file"
                    onChange={(e) => e.target.files[0] && uploadFile(e.target.files[0])}
                    style={{ display: "none" }}
                    id="file-input"
                />
                <label htmlFor="file-input" style={{ display: "block", marginTop: 8 }}>
                    Browse files
                </label>
            </div>
            {error && <p style={{ color: "red" }}>{error}</p>}
            {url && <p>File URL: <a href={url}>{url}</a></p>}
        </div>
    );
}

export default DropZoneUploader;

Notice that this component sends the file to /api/upload instead of directly to FilePost. This is the pattern you should use in production, where the Next.js API route acts as a proxy. Let's build that route next.

Next.js API Route (App Router)

In a Next.js application, you should proxy uploads through a server-side API route. This keeps your FilePost API key out of the browser and lets you add server-side validation, logging, or rate limiting.

Create the file at app/api/upload/route.js:

import { NextResponse } from "next/server";

export async function POST(request) {
    const formData = await request.formData();
    const file = formData.get("file");

    if (!file) {
        return NextResponse.json(
            { error: "No file provided" },
            { status: 400 }
        );
    }

    // Optional: server-side validation
    const maxSize = 200 * 1024 * 1024; // 200MB for Starter plan
    if (file.size > maxSize) {
        return NextResponse.json(
            { error: "File exceeds 200MB limit" },
            { status: 413 }
        );
    }

    // Forward to FilePost
    const uploadForm = new FormData();
    uploadForm.append("file", file);

    const response = await fetch("https://filepost.dev/v1/upload", {
        method: "POST",
        headers: { "X-API-Key": process.env.FILEPOST_API_KEY },
        body: uploadForm,
    });

    if (!response.ok) {
        const errorText = await response.text();
        return NextResponse.json(
            { error: `FilePost error: ${errorText}` },
            { status: response.status }
        );
    }

    const data = await response.json();
    return NextResponse.json(data);
}

Add your API key to .env.local:

FILEPOST_API_KEY=your_api_key_here

Now your React components can upload to /api/upload without ever exposing the API key to the client.

Next.js Pages Router alternative

If you are using the Pages Router, create pages/api/upload.js instead:

export const config = {
    api: {
        bodyParser: false, // Required for file uploads
    },
};

export default async function handler(req, res) {
    if (req.method !== "POST") {
        return res.status(405).json({ error: "Method not allowed" });
    }

    // Read the raw body and forward it to FilePost
    const chunks = [];
    for await (const chunk of req) {
        chunks.push(chunk);
    }
    const body = Buffer.concat(chunks);

    const response = await fetch("https://filepost.dev/v1/upload", {
        method: "POST",
        headers: {
            "X-API-Key": process.env.FILEPOST_API_KEY,
            "Content-Type": req.headers["content-type"],
        },
        body: body,
    });

    const data = await response.json();
    return res.status(response.status).json(data);
}

The key detail here is bodyParser: false. Next.js parses request bodies by default, which corrupts multipart file data. Disabling the body parser lets you forward the raw multipart request to FilePost with the original boundaries intact.

Custom React Hook for Reusability

If your application has multiple upload points (profile pictures, document attachments, media galleries), extract the upload logic into a custom hook:

import { useState, useCallback } from "react";

export function useFileUpload(endpoint = "/api/upload") {
    const [url, setUrl] = useState(null);
    const [fileId, setFileId] = useState(null);
    const [error, setError] = useState(null);
    const [loading, setLoading] = useState(false);

    const upload = useCallback(async (file) => {
        setLoading(true);
        setError(null);
        setUrl(null);
        setFileId(null);

        const formData = new FormData();
        formData.append("file", file);

        try {
            const response = await fetch(endpoint, {
                method: "POST",
                body: formData,
            });

            if (!response.ok) {
                const text = await response.text();
                throw new Error(text || `Upload failed with status ${response.status}`);
            }

            const data = await response.json();
            setUrl(data.url);
            setFileId(data.file_id);
            return data;
        } catch (err) {
            setError(err.message);
            return null;
        } finally {
            setLoading(false);
        }
    }, [endpoint]);

    const reset = useCallback(() => {
        setUrl(null);
        setFileId(null);
        setError(null);
        setLoading(false);
    }, []);

    return { upload, url, fileId, error, loading, reset };
}

// Usage in any component:
function AvatarUploader() {
    const { upload, url, loading, error } = useFileUpload();

    return (
        <div>
            <input
                type="file"
                accept="image/*"
                onChange={(e) => e.target.files[0] && upload(e.target.files[0])}
                disabled={loading}
            />
            {loading && <p>Uploading...</p>}
            {error && <p>{error}</p>}
            {url && <img src={url} alt="Avatar" width={100} />}
        </div>
    );
}

This hook handles all the state management and error handling. Any component can call useFileUpload() and get a clean upload function plus reactive state variables.

Handling Multiple File Uploads

For batch uploads, you can extend the pattern to handle an array of files with individual progress tracking:

import { useState } from "react";

function MultiFileUploader() {
    const [results, setResults] = useState([]);
    const [loading, setLoading] = useState(false);

    async function handleFiles(event) {
        const files = Array.from(event.target.files);
        if (files.length === 0) return;

        setLoading(true);
        setResults([]);

        const uploads = files.map(async (file) => {
            const formData = new FormData();
            formData.append("file", file);

            try {
                const response = await fetch("/api/upload", {
                    method: "POST",
                    body: formData,
                });

                if (!response.ok) throw new Error("Failed");

                const data = await response.json();
                return { name: file.name, url: data.url, status: "success" };
            } catch {
                return { name: file.name, url: null, status: "error" };
            }
        });

        const completed = await Promise.all(uploads);
        setResults(completed);
        setLoading(false);
    }

    return (
        <div>
            <input type="file" multiple onChange={handleFiles} disabled={loading} />
            {loading && <p>Uploading {results.length} files...</p>}
            {results.map((r, i) => (
                <div key={i}>
                    {r.name}: {r.status === "success"
                        ? <a href={r.url}>{r.url}</a>
                        : <span style={{ color: "red" }}>Failed</span>
                    }
                </div>
            ))}
        </div>
    );
}

export default MultiFileUploader;

This uploads all files in parallel using Promise.all. For large batches, consider limiting concurrency with Promise.allSettled or a queue to avoid overwhelming the browser's connection pool.

Error Handling Best Practices

A production file upload component should handle these specific error cases:

HTTP Status Meaning User-facing message
400 No file in request "Please select a file to upload."
401 Invalid API key "Authentication error. Please try again later."
413 File too large "This file exceeds the size limit. Please choose a smaller file."
429 Monthly limit reached "Upload limit reached. Please try again next month or upgrade your plan."
500 Server error "Something went wrong. Please try again."
Network error Connection failed "Could not connect. Check your internet connection."

Here is a helper function that maps these status codes to user-friendly messages:

function getUploadErrorMessage(status, serverMessage) {
    switch (status) {
        case 400: return "Please select a file to upload.";
        case 401: return "Authentication error. Please try again later.";
        case 413: return "This file exceeds the size limit. Please choose a smaller file.";
        case 429: return "Upload limit reached for this month.";
        default:  return serverMessage || "Something went wrong. Please try again.";
    }
}

Ship File Uploads in Minutes

FilePost gives you 300 free uploads per month. One endpoint, instant CDN URLs, and zero configuration. Perfect for React and Next.js apps.

Get Your Free API Key

TypeScript Types

If you are using TypeScript, here are the types for the FilePost API response:

interface FilePostUploadResponse {
    url: string;
    file_id: string;
    size: number;
}

interface FilePostError {
    error: string;
}

async function uploadToFilePost(file: File): Promise<FilePostUploadResponse> {
    const formData = new FormData();
    formData.append("file", file);

    const response = await fetch("/api/upload", {
        method: "POST",
        body: formData,
    });

    if (!response.ok) {
        const error: FilePostError = await response.json();
        throw new Error(error.error);
    }

    return response.json();
}

Production Checklist

Before deploying your file upload feature, make sure you have covered these items:

Next Steps

With these patterns, you can add file uploads to any React or Next.js application in minutes. The examples in this guide use FilePost as the hosting API because its single-endpoint design maps cleanly to React's component model: one fetch call, one response, one URL.

For the complete API reference, including listing and deleting files, check the FilePost API documentation. If you need the full technical breakdown of multipart/form-data, boundary strings, and server-side upload patterns, read the REST API file upload guide.