Fix: UploadThing Not Working — File Upload Failing, CORS Errors, or Route Not Found
Part of: React & Frontend Errors
Quick Answer
How to fix UploadThing issues — file router configuration, Next.js App Router and Pages Router setup, CORS, file type restrictions, progress callbacks, and deployment.
The Problem
An UploadThing file upload fails with a route error:
Error: No file routes found. Make sure you have a file router set up.
UploadThingError: INVALID_SERVER_CONFIGOr the upload endpoint returns 404:
POST /api/uploadthing → 404 Not FoundOr the upload succeeds but the onClientUploadComplete callback never fires:
const { startUpload } = useUploadThing("imageUploader", {
onClientUploadComplete: (res) => {
console.log("Upload done:", res); // Never called
},
});Why This Happens
UploadThing routes requests through a server-side file router that must be registered at a specific API endpoint. Most issues come from:
- File router not exported correctly — UploadThing needs a named export from your API route that matches what the client expects.
- Endpoint URL mismatch — the client and server must agree on the API path (default:
/api/uploadthing). Any mismatch causes a 404. - Missing or incorrect environment variables — UploadThing requires
UPLOADTHING_TOKEN(v7+) orUPLOADTHING_SECRET+UPLOADTHING_APP_ID(v6) to be set on the server. - App Router vs Pages Router differences — the setup is slightly different between the two, and mixing patterns causes the route to never be found.
The upload flow itself is what’s hardest to debug because three round trips happen for every file. First, the client posts metadata to your route handler, which runs your middleware() and returns a signed upload URL pointing at UploadThing’s storage. Second, the browser uploads the file directly to that URL, bypassing your server. Third, UploadThing calls back into your onUploadComplete() over an internal webhook. If any of the three steps fails, you get a different symptom: a failed first step looks like INVALID_SERVER_CONFIG, a failed second step looks like a stuck progress bar, and a failed third step looks like onClientUploadComplete never firing. Diagnosing the right step is the key to fixing the right thing.
There’s also a subtle behavior with the App Router and Edge runtime. UploadThing’s createRouteHandler requires the Node runtime because it uses Node crypto for signing. If you put export const runtime = 'edge' at the top of app/api/uploadthing/route.ts, the route compiles, the GET handler works, but the POST handler returns 500 or hangs on the first upload. The error message doesn’t mention the runtime; it usually surfaces as “Failed to fetch” on the client side.
Fix 1: Environment Variables
# .env.local — get these from uploadthing.com → Dashboard → your app
# UploadThing v7+
UPLOADTHING_TOKEN=eyJhb... # Single token replaces the two below
# UploadThing v6 (legacy)
UPLOADTHING_SECRET=sk_live_abc123...
UPLOADTHING_APP_ID=yourappidGet the token from uploadthing.com → Dashboard → your app → API Keys.
Warning: UPLOADTHING_TOKEN is a server-only secret. Never prefix it with NEXT_PUBLIC_ or expose it to the client.
How Other Tools Handle This
File upload is one of those problems where every solution has a different trade-off between developer ergonomics, cost, and operational control.
Cloudinary is the all-in-one media platform. It handles upload, transformation, CDN delivery, and AI tagging in a single API. The developer experience is excellent — cl_image_upload_tag or the React SDK gives you a working uploader in five lines — but pricing scales with transformations and bandwidth, not just storage. UploadThing handles upload and CDN delivery only; for transforms you bolt on a separate image pipeline. If you need on-the-fly resize, format conversion, and face detection, Cloudinary is the shorter path.
AWS S3 + presigned URLs is the most flexible and cheapest at scale. You generate a presigned PUT URL on the server, the client uploads directly to S3, and your server gets notified via S3 Event Notifications (SNS, SQS, or Lambda). The downsides are the operational complexity (IAM policies, CORS on the bucket, lifecycle rules) and that you write all the wiring yourself — no <UploadButton /> equivalent. UploadThing wraps this same pattern in a SaaS, so the cost difference makes sense at very high volume. See Fix: AWS S3 CORS Error for the CORS bucket policy that most S3-direct flows need.
Vercel Blob is the Vercel-native equivalent. It exposes a put() function that returns a public URL, runs on Vercel’s edge network, and integrates with the rest of the Vercel platform (deployments, environment variables, billing). The API is simpler than UploadThing’s file router because there’s no middleware — Vercel Blob trusts your route handler to authorize the upload. The trade-off is no per-route schema (file type, size limits) — you enforce those yourself. See Fix: Vercel Blob Not Working for the most common configuration mistakes.
Cloudflare R2 is S3-compatible storage with no egress fees. You can point any S3 SDK at R2 and get free bandwidth in exchange for slightly higher latency than S3 in non-Cloudflare regions. R2 is the cheapest option for high-egress workloads (image hosting, video, downloads), and pairs naturally with Cloudflare Workers for the upload signing. It does not include UploadThing’s onUploadComplete callback model — you handle webhooks via R2 event notifications. See Fix: Cloudflare R2 Not Working.
Backblaze B2 is the budget S3 alternative. Pricing is dramatically lower than S3 ($0.006/GB/month vs $0.023) and egress to Cloudflare is free via the Bandwidth Alliance. The B2 native API is simpler than S3’s, though most teams use the S3-compatible endpoint for SDK reuse. B2 lacks the dashboard polish of UploadThing or Vercel Blob, but for cold storage and large archives, it’s the cheapest option that’s still production-grade.
A useful rule: choose UploadThing or Vercel Blob when you want zero ops, choose Cloudinary when you need image transforms, choose R2 or B2 when egress cost dominates, and choose direct S3 when you need IAM-level control or already have an AWS footprint. The INVALID_SERVER_CONFIG error you’re debugging only exists in UploadThing — the equivalent in S3 is a 403 from the presigned URL, and the equivalent in R2 is a SignatureDoesNotMatch response.
Fix 2: File Router (Core Configuration)
// src/app/api/uploadthing/core.ts — define what uploads are allowed
import { createUploadthing, type FileRouter } from 'uploadthing/next';
import { UploadThingError } from 'uploadthing/server';
const f = createUploadthing();
// Middleware to auth the upload request
const auth = (req: Request) => ({ userId: 'user_123' }); // Replace with real auth
export const ourFileRouter = {
// Route name used in the client
imageUploader: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } })
.middleware(async ({ req }) => {
const user = await auth(req);
if (!user) throw new UploadThingError('Unauthorized');
// Whatever is returned here is available in onUploadComplete
return { userId: user.userId };
})
.onUploadComplete(async ({ metadata, file }) => {
console.log('Upload complete for userId:', metadata.userId);
console.log('File URL:', file.url);
// Return data to the client's onClientUploadComplete callback
return { uploadedBy: metadata.userId, url: file.url };
}),
// Multiple file types
documentUploader: f({
pdf: { maxFileSize: '16MB' },
'application/msword': { maxFileSize: '8MB' },
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { maxFileSize: '8MB' },
})
.middleware(async ({ req }) => {
const user = await auth(req);
if (!user) throw new UploadThingError('Unauthorized');
return { userId: user.userId };
})
.onUploadComplete(async ({ metadata, file }) => {
return { url: file.url, name: file.name };
}),
// Multiple images
galleryUploader: f({ image: { maxFileSize: '8MB', maxFileCount: 10 } })
.middleware(async ({ req }) => ({ userId: 'user_123' }))
.onUploadComplete(async ({ metadata, file }) => {
return { url: file.url };
}),
} satisfies FileRouter;
export type OurFileRouter = typeof ourFileRouter;Fix 3: API Route — Next.js App Router
// src/app/api/uploadthing/route.ts
import { createRouteHandler } from 'uploadthing/next';
import { ourFileRouter } from './core';
// Export GET and POST handlers — both are required
export const { GET, POST } = createRouteHandler({
router: ourFileRouter,
config: {
// Optional: override the URL if behind a proxy
// uploadthingId: process.env.UPLOADTHING_APP_ID,
},
});The file must be at exactly src/app/api/uploadthing/route.ts (or app/api/uploadthing/route.ts if your src/ directory is the root). The path /api/uploadthing must be reachable.
Fix 4: API Route — Next.js Pages Router
// src/pages/api/uploadthing.ts
import { createNextPageApiHandler } from 'uploadthing/next-legacy';
import { ourFileRouter } from './core'; // Adjust import path as needed
// core.ts lives in src/server/uploadthing.ts or similar
export default createNextPageApiHandler({ router: ourFileRouter });
export const config = {
api: {
bodyParser: false, // Required — UploadThing handles the body
},
};Fix 5: Client-Side Components
// src/utils/uploadthing.ts — generate type-safe hooks
import { generateUploadButton, generateUploadDropzone, generateReactHelpers } from '@uploadthing/react';
import type { OurFileRouter } from '@/app/api/uploadthing/core';
export const UploadButton = generateUploadButton<OurFileRouter>();
export const UploadDropzone = generateUploadDropzone<OurFileRouter>();
export const { useUploadThing, uploadFiles } = generateReactHelpers<OurFileRouter>();// components/ImageUpload.tsx — using the pre-built button
'use client';
import { UploadButton } from '@/utils/uploadthing';
export function ImageUpload({ onUpload }: { onUpload: (url: string) => void }) {
return (
<UploadButton
endpoint="imageUploader" // Must match a key in yourFileRouter
onClientUploadComplete={(res) => {
// res is the array returned from onUploadComplete
console.log('Files:', res);
if (res?.[0]?.url) {
onUpload(res[0].url);
}
}}
onUploadError={(error: Error) => {
console.error('Upload error:', error.message);
alert(`Upload failed: ${error.message}`);
}}
onUploadBegin={(name) => {
console.log('Uploading:', name);
}}
/>
);
}// components/FileDropzone.tsx — drag and drop with progress
'use client';
import { UploadDropzone } from '@/utils/uploadthing';
export function FileDropzone() {
return (
<UploadDropzone
endpoint="documentUploader"
onClientUploadComplete={(res) => {
console.log('Upload complete:', res);
}}
onUploadError={(error: Error) => {
console.error('Error:', error.message);
}}
onUploadProgress={(progress) => {
console.log(`Upload progress: ${progress}%`);
}}
appearance={{
button: 'bg-blue-600 hover:bg-blue-700',
dropzone: 'border-2 border-dashed border-gray-300 rounded-lg p-8',
}}
/>
);
}// Using the useUploadThing hook for custom UI
'use client';
import { useUploadThing } from '@/utils/uploadthing';
import { useState, useCallback } from 'react';
export function CustomUploader() {
const [files, setFiles] = useState<File[]>([]);
const [progress, setProgress] = useState(0);
const { startUpload, isUploading } = useUploadThing('imageUploader', {
onClientUploadComplete: (res) => {
console.log('Done:', res);
setProgress(100);
},
onUploadError: (err) => {
console.error('Error:', err);
},
onUploadProgress: (p) => {
setProgress(p);
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files));
}
};
return (
<div>
<input type="file" accept="image/*" onChange={handleFileChange} />
<button
onClick={() => startUpload(files)}
disabled={isUploading || files.length === 0}
>
{isUploading ? `Uploading... ${progress}%` : 'Upload'}
</button>
{isUploading && (
<progress value={progress} max={100} className="w-full mt-2" />
)}
</div>
);
}Fix 6: Middleware Auth Integration
// With NextAuth.js / Auth.js
import { auth } from '@/auth'; // Your auth setup
import { createUploadthing, type FileRouter } from 'uploadthing/next';
import { UploadThingError } from 'uploadthing/server';
const f = createUploadthing();
export const ourFileRouter = {
avatarUploader: f({ image: { maxFileSize: '2MB' } })
.middleware(async ({ req }) => {
const session = await auth();
if (!session?.user?.id) throw new UploadThingError('Unauthorized');
return { userId: session.user.id };
})
.onUploadComplete(async ({ metadata, file }) => {
// Save the URL to the database
await db.update(users).set({ avatarUrl: file.url }).where(eq(users.id, metadata.userId));
return { url: file.url };
}),
} satisfies FileRouter;
export type OurFileRouter = typeof ourFileRouter;// With Clerk
import { auth } from '@clerk/nextjs/server';
import { UploadThingError } from 'uploadthing/server';
.middleware(async ({ req }) => {
const { userId } = await auth();
if (!userId) throw new UploadThingError('Unauthorized');
return { userId };
})Fix 7: File Validation
// Allowed file type strings
f({
image: { maxFileSize: '4MB', maxFileCount: 4 }, // JPEG, PNG, GIF, WebP, etc.
video: { maxFileSize: '256MB', maxFileCount: 1 },
audio: { maxFileSize: '64MB' },
pdf: { maxFileSize: '32MB' },
text: { maxFileSize: '1MB' },
blob: { maxFileSize: '128MB' }, // Any file type
// Specific MIME types
'image/png': { maxFileSize: '4MB' },
'application/zip': { maxFileSize: '64MB' },
})// Custom validation in middleware
.middleware(async ({ req, files }) => {
// files is an array of { name, size, type } metadata
for (const file of files) {
if (file.size > 5 * 1024 * 1024) {
throw new UploadThingError('File too large. Maximum size is 5MB.');
}
if (!file.name.endsWith('.pdf') && !file.name.endsWith('.docx')) {
throw new UploadThingError('Only PDF and DOCX files are allowed.');
}
}
return { userId: 'user_123' };
})Fix 8: Deployment and Environment
# Vercel — add environment variables in project settings
UPLOADTHING_TOKEN=eyJ...
# The UPLOADTHING_TOKEN is automatically detected.
# If behind a custom domain, set the URL:
UPLOADTHING_URL=https://your-domain.com// If your API route is not at /api/uploadthing, configure the client:
export const { useUploadThing } = generateReactHelpers<OurFileRouter>({
url: '/custom/api/path/uploadthing',
});// For non-Next.js (Express, Hono, etc.)
import { createRouteHandler } from 'uploadthing/server';
import { ourFileRouter } from './router';
// Hono
import { Hono } from 'hono';
const app = new Hono();
const handlers = createRouteHandler({ router: ourFileRouter });
app.on(['GET', 'POST'], '/api/uploadthing', (c) => handlers(c.req.raw));Still Not Working?
404 on POST /api/uploadthing — verify the file exists at src/app/api/uploadthing/route.ts (App Router) or pages/api/uploadthing.ts (Pages Router). Check that you’re not filtering this route in Next.js middleware.
INVALID_SERVER_CONFIG error — UPLOADTHING_TOKEN is missing or invalid. Copy it from uploadthing.com and add it to .env.local. Restart the dev server after adding env variables.
onClientUploadComplete never fires — the return value from onUploadComplete on the server is what gets passed to the client callback. If the server handler throws, the client callback won’t fire. Check server logs for errors in the handler.
File type rejected — your core.ts file router only accepts types listed in f({...}). If you pass a file of a different type, UploadThing rejects it before the middleware runs. Add the MIME type or use blob to accept any type.
CORS error — if the upload goes to a different domain than your app, add the headers() config in the route handler, or configure your allowed origins in the UploadThing dashboard.
Edge runtime causes silent failures — UploadThing requires the Node runtime in App Router. Remove any export const runtime = 'edge' from app/api/uploadthing/route.ts. The Edge runtime lacks the Node crypto primitives UploadThing uses for signing webhooks, so POST requests fail without a clear error.
Uploads succeed but the URL returns 404 minutes later — your UploadThing plan has expired or you’ve exceeded the free quota. Files are deleted, not just made inaccessible. Check the dashboard’s usage page, and consider migrating large files to a permanent backing store via the onUploadComplete hook (download from UploadThing’s URL, re-upload to S3 or R2, save the new URL to your database).
Webhook from UploadThing to your onUploadComplete never arrives — your deployment is behind authentication (Vercel password protection, Cloudflare Access). UploadThing’s internal callback cannot solve auth challenges. Either disable protection on the /api/uploadthing path or set up a bypass token in your provider’s settings.
For related file handling and storage, see Fix: Next.js API Route Not Working and Fix: Vercel Blob Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Clerk Not Working — Auth Not Loading, Middleware Blocking, or User Data Missing
How to fix Clerk authentication issues — ClerkProvider setup, middleware configuration, useUser and useAuth hooks, server-side auth, webhook handling, and organization features.
Fix: Contentlayer Not Working — Content Not Generated, Types Missing, or Build Errors
How to fix Contentlayer and Contentlayer2 issues — content source configuration, document type definitions, MDX processing, computed fields, Next.js integration, and migration to alternatives.
Fix: next-safe-action Not Working — Action Not Executing, Validation Errors Missing, or Type Errors
How to fix next-safe-action issues — action client setup, Zod schema validation, useAction and useOptimisticAction hooks, middleware, error handling, and authorization patterns.
Fix: nuqs Not Working — URL State Not Syncing, Type Errors, or Server Component Issues
How to fix nuqs URL search params state management — useQueryState and useQueryStates setup, parsers, server-side access, shallow routing, history mode, and Next.js App Router integration.