Next.js
@devstorage/next gives you a typed upload router for your API routes, prebuilt UploadButton / UploadDropzone components, and React hooks. Files stream directly from the browser to storage — your API key stays on the server, and route names, inputs, and server data are type-checked end to end.
Install
npm install @devstorage/nextSet DEVSTORAGE_API_KEY in your environment (see Getting started).
1. Define your upload routes
Create an API route that mounts the router. Each named route carries its own limits, authorization, and completion logic:
import { createUploadRouter, route, UploadHandlerError } from "@devstorage/next/server";
import { z } from "zod";
export const router = {
// Different uploads, different rules.
avatars: route({
allowedMimeTypes: ["image/"], // trailing "/" = prefix match
maxSizeBytes: 4 * 1024 * 1024, // 4 MB
})
.onBeforeUpload(async ({ request }) => {
const user = await getUser(request);
if (!user) throw new UploadHandlerError(401, "Sign in to upload");
return { metadata: { userId: user.id } };
})
// The return value reaches the browser as `serverData`, fully typed.
.onUploadComplete(async ({ file }) => {
const row = await db.files.create({ id: file.id, url: file.url });
return { rowId: row.id };
}),
attachments: route({ maxFileCount: 10, maxSizeBytes: 512 * 1024 * 1024 })
// Validate client-sent JSON with zod, valibot, arktype, or a function.
.input(z.object({ postId: z.string() }))
.onBeforeUpload(async ({ input }) => ({
metadata: { postId: input.postId },
})),
};
export type UploadRouter = typeof router;
export const { POST, GET } = createUploadRouter(router);Export GET alongside POST— it serves each route's public limits so the components and hooks can pre-validate files and set accept attributes before any bytes move.
Route limits
| Option | Description |
|---|---|
maxSizeBytes | Reject larger files with 413 before presigning. |
allowedMimeTypes | Allow-list; entries ending in /match by prefix ("image/" allows any image). 415 otherwise. |
maxFileCount / minFileCount | Files allowed per upload call. Default 1 / 1; use Infinity for no cap. |
multipartThreshold | Bytes at/above which the chunked multipart flow is used. Default 100 MiB. |
2. Drop in a component
The prebuilt components read the route's config from your handler, set the file input's accept/multiple for you, and show the limits as help text:
"use client";
import { generateUploadButton, generateUploadDropzone } from "@devstorage/next/react";
import type { UploadRouter } from "@/app/api/devstorage/route";
const UploadButton = generateUploadButton<UploadRouter>();
const UploadDropzone = generateUploadDropzone<UploadRouter>();
export function AvatarUploader() {
return (
<UploadButton
route="avatars"
onClientUploadComplete={(files) => {
console.log(files[0].serverData.rowId); // typed!
}}
onUploadError={(err) => alert(err.message)}
/>
);
}
export function AttachmentDrop({ postId }: { postId: string }) {
return <UploadDropzone route="attachments" input={{ postId }} />;
}Theming
Every element accepts a class name, inline styles, or a function of the component state via appearance, and custom nodes via content. For plain CSS, target the data-ds-element and data-state attributes.
<UploadButton
route="avatars"
appearance={{
button: "rounded-full bg-black px-6", // class names…
allowedContent: { color: "#a8a29e" }, // …or inline styles
}}
content={{
button: ({ isUploading, progress }) =>
isUploading ? `${progress}%` : "Upload avatar",
}}
/>Hooks
Prefer your own UI? generateReactHelpersreturns hooks bound to your router's types:
"use client";
import { generateReactHelpers } from "@devstorage/next/react";
import type { UploadRouter } from "@/app/api/devstorage/route";
export const { useUpload, uploadFiles, useRouteConfig } =
generateReactHelpers<UploadRouter>();
function Uploader() {
const { upload, progress, status, files, error } = useUpload("avatars");
const { accept, multiple } = useRouteConfig("avatars");
return (
<div>
<input
type="file"
accept={accept}
multiple={multiple}
onChange={(e) => e.target.files?.length && upload(e.target.files)}
/>
{status === "uploading" && <progress value={progress} max={100} />}
{status === "success" && <a href={files![0].url}>{files![0].name}</a>}
{status === "error" && <p>{error!.message}</p>}
</div>
);
}The untyped useUpload / uploadFiles / uploadFile exports work the same way without the generics.
Plain client (no React)
import { uploadFiles } from "@devstorage/next/client";
const records = await uploadFiles(fileList, {
route: "attachments",
input: { postId },
onProgress: (p) => console.log(`${p.percent}%`), // overall
onFileProgress: ({ name, progress }) => {}, // per file
});Client options
| Option | Description |
|---|---|
endpoint | Route-handler path. Defaults to /api/devstorage. |
route | Named route. Optional when the router has one route. |
input | Payload for the route's .input() validator. |
onProgress / onFileProgress | Overall / per-file { loaded, total, percent }. |
concurrency | Max PUTs in flight across files and multipart parts. Default 3. |
headers | Extra headers for the route handler — an object or async factory. |
signal | AbortSignal to cancel; in-flight sessions are aborted server-side too. |
Failures throw UploadError with a status and machine-readable code — file_too_large, too_many_files, file_type_not_allowed, aborted, …
Pages Router
import { createPagesUploadHandler } from "@devstorage/next/pages";
import { route } from "@devstorage/next/server";
export default createPagesUploadHandler({
files: route({ maxFileCount: 5 }),
});The core is a plain Request → Response function (handleUploadRequest), so it also mounts in any fetch-based framework.
serverData vs. webhooks
onUploadComplete fires when the browser confirms the upload — a client that closes the tab skips it. Use it for UX (returning serverData), and use webhooks as the source of truth for billing and records.