How to Integrate Uploadthing with Astro: Complete Guide
Step-by-step guide to integrating Uploadthing with your Astro website.
Uploadthing is a file upload service built specifically for TypeScript developers. Instead of cobbling together S3 buckets, presigned URLs, and upload validation yourself, Uploadthing gives you a type-safe API that handles file uploads from end to end. You define what files are allowed (type, size, count), and Uploadthing handles the rest: client-side uploads, server-side validation, and storage.
For Astro projects that need file uploads (user avatars, blog images, document attachments), Uploadthing eliminates a lot of boilerplate. The files are stored on Uploadthing's infrastructure and served through a CDN.
Prerequisites
- Node.js 22.12.0 or newer. Astro 6 raised its minimum supported Node version, so the older "Node 18+" advice you may have seen no longer applies (see the Astro
enginesfield on npm, checked below). - An Astro 6 project. Create one with
npm create astro@latest. - A server runtime adapter so upload routes can run on demand (covered in the next sections).
- An Uploadthing account. The dashboard gives you a single app token.
Versions referenced in this guide, pinned from registry.npmjs.org on 2026-05-30:
| Package | Version |
|---|---|
astro |
6.4.2 |
uploadthing |
7.7.4 |
@uploadthing/react |
7.3.3 |
@astrojs/react |
5.0.6 |
@astrojs/node |
10.1.2 |
Installation
npm install uploadthing@7.7.4 @uploadthing/react@7.3.3
The package names uploadthing and @uploadthing/react are both still current on npm. If you are not using React islands in Astro, you can skip @uploadthing/react and use the core library directly.
Configuration
Authentication changed in Uploadthing v7. The old pair of UPLOADTHING_SECRET and UPLOADTHING_APP_ID variables has been replaced by a single UPLOADTHING_TOKEN. Grab the token from the Uploadthing dashboard (API Keys section) and add it to .env:
UPLOADTHING_TOKEN=...
Enable On-Demand Rendering
Uploadthing's route handler runs server-side, so its endpoint must be rendered on demand rather than prerendered. In current Astro this is configured per route, not globally.
A few things have changed in recent Astro versions that older tutorials get wrong:
output: 'hybrid'was removed in Astro 5. The previous hybrid behavior became the default and was folded intooutput: 'static'. Do not setoutput: 'hybrid'; it is no longer a valid value.staticis now the default output. You do not need to setoutputat all to mix static pages with on-demand endpoints.- Any route that must run on the server opts out with
export const prerender = false, and on-demand rendering requires an adapter.
Add a server adapter (the Node adapter shown here, but any official adapter works):
npx astro add node
That installs @astrojs/node and wires it into astro.config.mjs:
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
adapter: node({ mode: "standalone" }),
// output defaults to "static"; individual routes opt out with prerender = false
});
If you use React islands for the upload button, also add the React integration:
npx astro add react
Upload Router
Create the upload router that defines what files your app accepts:
// src/lib/uploadthing.ts
import { createUploadthing, type FileRouter } from "uploadthing/server";
const f = createUploadthing();
export const uploadRouter = {
blogImage: f({ image: { maxFileSize: "4MB", maxFileCount: 1 } })
.middleware(async ({ req }) => {
// Add auth checks here if needed
return { uploadedBy: "admin" };
})
.onUploadComplete(async ({ metadata, file }) => {
console.log("Upload complete:", file.ufsUrl);
return { url: file.ufsUrl };
}),
avatar: f({ image: { maxFileSize: "2MB", maxFileCount: 1 } })
.middleware(async ({ req }) => {
return { uploadedBy: "user" };
})
.onUploadComplete(async ({ metadata, file }) => {
return { url: file.ufsUrl };
}),
document: f({
pdf: { maxFileSize: "16MB", maxFileCount: 5 },
"image/png": { maxFileSize: "4MB", maxFileCount: 5 },
})
.middleware(async () => {
return {};
})
.onUploadComplete(async ({ file }) => {
return { url: file.ufsUrl };
}),
} satisfies FileRouter;
export type OurFileRouter = typeof uploadRouter;
Note the file.ufsUrl property. In Uploadthing v7 the file object's ufsUrl is the recommended URL field. The older file.url still exists for now but the v7 migration guide flags it as legacy in favor of the per-app <APP_ID>.ufs.sh/f/<FILE_KEY> format that ufsUrl returns.
API Route Setup
Create an API route that Uploadthing uses for its upload handshake. The current Astro setup from the official docs exports the handlers directly rather than wrapping them in custom APIRoute functions, and it passes the token explicitly through config:
// src/pages/api/uploadthing.ts
import { createRouteHandler } from "uploadthing/server";
import { uploadRouter } from "../../lib/uploadthing";
// This endpoint must run on demand, not be prerendered.
export const prerender = false;
const handlers = createRouteHandler({
router: uploadRouter,
config: {
token: import.meta.env.UPLOADTHING_TOKEN,
},
});
export { handlers as GET, handlers as POST };
Two things to note. First, export const prerender = false is what tells Astro to render this endpoint on demand instead of trying to build it statically; without it (and without an adapter) the handshake route will not exist at runtime. Second, createRouteHandler returns an object whose GET and POST are valid Astro route handlers, so the official guide re-exports them directly with export { handlers as GET, handlers as POST }.
Client-Side Upload with Vanilla JavaScript
If you are not using React, do not POST raw FormData to the endpoint yourself. Uploadthing's upload is a multi-step handshake (the server hands back presigned upload targets), not a single multipart POST. The framework-agnostic helper for this is genUploader from uploadthing/client, which gives you a typed uploadFiles function that drives the whole handshake for you:
---
// src/components/FileUploader.astro
---
<div id="upload-zone">
<input type="file" id="file-input" accept="image/*" />
<button id="upload-btn" disabled>Upload</button>
<p id="upload-status"></p>
<img id="upload-preview" style="max-width: 300px; display: none;" alt="Uploaded file preview" />
</div>
<script>
import { genUploader } from "uploadthing/client";
import type { OurFileRouter } from "../lib/uploadthing";
const { uploadFiles } = genUploader<OurFileRouter>();
const input = document.getElementById("file-input") as HTMLInputElement;
const btn = document.getElementById("upload-btn") as HTMLButtonElement;
const status = document.getElementById("upload-status") as HTMLParagraphElement;
const preview = document.getElementById("upload-preview") as HTMLImageElement;
input.addEventListener("change", () => {
btn.disabled = !input.files?.length;
});
btn.addEventListener("click", async () => {
const file = input.files?.[0];
if (!file) return;
status.textContent = "Uploading...";
btn.disabled = true;
try {
const res = await uploadFiles("blogImage", { files: [file] });
const url = res[0]?.ufsUrl;
if (url) {
status.textContent = "Upload complete!";
preview.src = url;
preview.style.display = "block";
} else {
status.textContent = "Upload returned no file";
}
} catch (err) {
status.textContent =
"Upload failed: " + (err instanceof Error ? err.message : "unknown");
}
btn.disabled = false;
});
</script>
uploadFiles takes the endpoint name ("blogImage", matching a key in your file router) and an options object with the files array. It returns the completed file records, each carrying ufsUrl and key. Because the endpoint name is typed against OurFileRouter, you get autocompletion and a compile error if you reference an endpoint that does not exist.
React Upload Component (Astro Island)
If you use React islands in Astro, Uploadthing generates typed components for you. In v7 the recommended pattern is generateUploadButton and generateUploadDropzone, which take your router type as a generic and return ready-to-use components. This replaces the older <UploadButton<OurFileRouter, "endpoint">> generic-on-the-JSX-element style some tutorials still show.
First, generate the components once in a shared module:
// src/lib/uploadthing-react.ts
import {
generateUploadButton,
generateUploadDropzone,
} from "@uploadthing/react";
import type { OurFileRouter } from "./uploadthing";
export const UploadButton = generateUploadButton<OurFileRouter>();
export const UploadDropzone = generateUploadDropzone<OurFileRouter>();
Then use them in your island component:
// src/components/ImageUploader.tsx
import { UploadButton } from "../lib/uploadthing-react";
export default function ImageUploader() {
return (
<UploadButton
endpoint="blogImage"
onClientUploadComplete={(res) => {
console.log("Files:", res);
alert("Upload complete! URL: " + res[0]?.ufsUrl);
}}
onUploadError={(error: Error) => {
alert("Error: " + error.message);
}}
/>
);
}
Use it in an Astro page:
---
import ImageUploader from "../components/ImageUploader";
---
<ImageUploader client:load />
The client:load directive is required. Without it the React island never hydrates, so the upload button renders but its click handlers and the upload handshake never run.
Inject Router Config for SSR (optional)
By default the client component fetches its permissions config from your server on mount. You can skip that round trip by injecting the router config from a server-rendered Astro component. Extract it with extractRouterConfig and expose it on the page:
---
// in your layout or page frontmatter
import { extractRouterConfig } from "uploadthing/server";
import { uploadRouter } from "../lib/uploadthing";
const routerConfig = extractRouterConfig(uploadRouter);
---
<script is:inline define:vars={{ routerConfig }}>
globalThis.__UPLOADTHING ??= routerConfig;
</script>
With the config present at hydration time, the upload component renders with no extra client fetch.
Handling Upload Callbacks
Uploadthing calls your onUploadComplete callback after a successful upload. Use this to save the file URL to your database or update your content:
blogImage: f({ image: { maxFileSize: "4MB" } })
.middleware(async ({ req }) => {
// Validate the user session
const session = await getSession(req);
if (!session) throw new Error("Unauthorized");
return { userId: session.userId };
})
.onUploadComplete(async ({ metadata, file }) => {
// Save to your database
await db.images.create({
url: file.ufsUrl,
key: file.key,
userId: metadata.userId,
size: file.size,
});
return { url: file.ufsUrl };
}),
Store file.key alongside file.ufsUrl. The key is what UTApi methods (like delete) operate on, and it is the stable identifier even if the URL host format changes.
Deleting Files
Remove files from Uploadthing when they are no longer needed:
// src/lib/uploadthing.ts (add to existing)
import { UTApi } from "uploadthing/server";
export const utapi = new UTApi();
export async function deleteFile(fileKey: string): Promise<void> {
await utapi.deleteFiles(fileKey);
}
Use it in an API route:
// src/pages/api/delete-file.ts
import type { APIRoute } from "astro";
import { deleteFile } from "../../lib/uploadthing";
export const prerender = false;
export const DELETE: APIRoute = async ({ request }) => {
const { fileKey } = await request.json();
await deleteFile(fileKey);
return new Response(JSON.stringify({ success: true }), { status: 200 });
};
Production Tips
Always validate in middleware. The middleware function runs server-side before any upload begins. Use it to check authentication, rate limits, and user permissions.
Store file keys, not just URLs. Uploadthing file keys are needed to delete or manage files later. Save both the URL (for display) and the key (for management) in your database.
Set strict file limits. Be specific about file types and sizes for each endpoint. A blog image uploader should not accept 100MB videos.
Handle errors gracefully. The
onUploadErrorcallback on the client receives specific error types (file too large, wrong type, server error). Show helpful messages to the user.Use webhooks for production. Instead of relying solely on
onUploadComplete, configure Uploadthing webhooks to receive upload notifications. This ensures you do not miss completions if the user closes their browser mid-upload.
Alternatives to Consider
- Cloudflare R2 + custom upload logic if you want full control over storage and zero egress fees.
- Supabase Storage if you are already using Supabase and want uploads integrated with your auth and database.
- tus protocol libraries if you need resumable uploads for very large files.
Common Errors and Fixes
The upload endpoint 404s or the handshake never completes. The Uploadthing route is being prerendered as static. Add export const prerender = false to src/pages/api/uploadthing.ts and confirm you have installed a server adapter. Astro is static by default, so any route that runs server logic must opt out of prerendering and the project needs an adapter for on-demand rendering.
Config error mentioning a missing token or secret. You are still using the v6 variables. Uploadthing v7 reads a single UPLOADTHING_TOKEN. Remove UPLOADTHING_SECRET and UPLOADTHING_APP_ID, set UPLOADTHING_TOKEN in .env, and pass it through config.token in createRouteHandler as shown above.
output: 'hybrid' throws a config error. That value was removed in Astro 5. The old hybrid behavior is now the default and lives under output: 'static'. Delete the output: 'hybrid' line. You do not need to set output at all to mix static pages with on-demand endpoints; mark the dynamic ones with prerender = false.
TypeScript complains that file.url is the wrong field, or your saved URLs use an unexpected host. Use file.ufsUrl. In v7 this is the recommended URL property and it returns the per-app <APP_ID>.ufs.sh/f/<FILE_KEY> format. The legacy file.url still resolves for now but the migration guide flags it as not recommended.
<UploadButton<OurFileRouter, "endpoint">> does not type-check. That generic-on-the-JSX-element pattern is from older @uploadthing/react. Generate the component instead with export const UploadButton = generateUploadButton<OurFileRouter>() and use it as a plain <UploadButton endpoint="blogImage" />.
The React upload button renders but clicking does nothing. The island did not hydrate. Astro ships zero JS by default, so add the client:load directive when you mount the component (<ImageUploader client:load />).
Vanilla JS upload fails when POSTing FormData directly to the endpoint. Uploadthing uploads are a multi-step presigned handshake, not a single multipart POST. Use genUploader from uploadthing/client and call uploadFiles("endpoint", { files }) rather than building the request by hand.
Astro refuses to start, citing the Node version. Astro 6 requires Node 22.12.0 or newer. Upgrade your runtime; the older Node 18 minimum no longer holds.
Alternatives to Consider
- Cloudflare R2 + custom upload logic if you want full control over storage and zero egress fees.
- Supabase Storage if you are already using Supabase and want uploads integrated with your auth and database.
- tus protocol libraries if you need resumable uploads for very large files.
Official Docs and Examples
- Uploadthing Astro setup guide (quickstart): https://docs.uploadthing.com/getting-started/astro
- Uploadthing v6 to v7 migration guide (token change,
ufsUrl): https://docs.uploadthing.com/v7 - Uploadthing client API reference (
genUploader,uploadFiles): https://docs.uploadthing.com/api-reference/client - Uploadthing React API reference (
generateUploadButton): https://docs.uploadthing.com/api-reference/react - Uploadthing working-with-files reference (
ufs.shURLs): https://docs.uploadthing.com/working-with-files - Uploadthing example apps (backend adapters, including server and client implementations): https://github.com/pingdotgg/uploadthing/tree/main/examples/backend-adapters
- Astro on-demand rendering and adapters guide: https://docs.astro.build/en/guides/on-demand-rendering/
- Astro v5 upgrade notes (removal of
output: 'hybrid'): https://docs.astro.build/en/guides/upgrade-to/v5/
Wrapping Up
Uploadthing takes the complexity out of file uploads for TypeScript projects. The type-safe file router, built-in validation, and managed storage mean you spend less time on upload infrastructure and more time on your actual product. For Astro apps that need file uploads, it is one of the fastest paths from zero to working uploads. Add an adapter, mark the upload route as on-demand, define your routes, drop in a component, and files land safely in the cloud.
Sources
All versions and facts below were checked on 2026-05-30.
- Uploadthing Astro setup guide (install command,
UPLOADTHING_TOKEN,createRouteHandler,generateUploadButton,file.ufsUrl,extractRouterConfig): https://docs.uploadthing.com/getting-started/astro - Uploadthing v6 to v7 migration guide (single-token auth,
ufsUrloverurl): https://docs.uploadthing.com/v7 - Uploadthing client reference (
genUploader/uploadFiles): https://docs.uploadthing.com/api-reference/client - Uploadthing React reference (
generateUploadButton/generateUploadDropzone): https://docs.uploadthing.com/api-reference/react - Uploadthing working-with-files reference (
<APP_ID>.ufs.sh/f/<FILE_KEY>): https://docs.uploadthing.com/working-with-files - Uploadthing example repository: https://github.com/pingdotgg/uploadthing/tree/main/examples/backend-adapters
- Astro on-demand rendering and adapter requirement: https://docs.astro.build/en/guides/on-demand-rendering/
- Astro v5 upgrade guide (
output: 'hybrid'removed, merged intostatic): https://docs.astro.build/en/guides/upgrade-to/v5/ - npm registry
astrolatest 5.14.1 andengines.node^18.20.8 || ^20.3.0 || >=22.0.0: https://registry.npmjs.org/astro/latest - npm registry
uploadthinglatest 7.7.4: https://registry.npmjs.org/uploadthing/latest - npm registry
@uploadthing/reactlatest 7.3.3: https://registry.npmjs.org/@uploadthing/react/latest - npm registry
@astrojs/reactlatest 4.4.0: https://registry.npmjs.org/@astrojs/react/latest - npm registry
@astrojs/nodelatest 9.4.4 (peer astro ^5.0.0): https://registry.npmjs.org/@astrojs/node/latest
Related Articles
How to Use Algolia with Astro: Complete Guide
Step-by-step guide to integrating Algolia with your Astro website.
How to Integrate Auth0 with Astro: Complete Guide
Step-by-step guide to integrating Auth0 with your Astro website. Setup, configuration, and best practices.
How to Use AWS Amplify with Astro: Complete Guide
Step-by-step guide to integrating AWS Amplify with your Astro website.