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/next

Set 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:

app/api/devstorage/route.ts
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

OptionDescription
maxSizeBytesReject larger files with 413 before presigning.
allowedMimeTypesAllow-list; entries ending in /match by prefix ("image/" allows any image). 415 otherwise.
maxFileCount / minFileCountFiles allowed per upload call. Default 1 / 1; use Infinity for no cap.
multipartThresholdBytes 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:

components/uploader.tsx
"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:

lib/uploads.ts
"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

OptionDescription
endpointRoute-handler path. Defaults to /api/devstorage.
routeNamed route. Optional when the router has one route.
inputPayload for the route's .input() validator.
onProgress / onFileProgressOverall / per-file { loaded, total, percent }.
concurrencyMax PUTs in flight across files and multipart parts. Default 3.
headersExtra headers for the route handler — an object or async factory.
signalAbortSignal 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

pages/api/devstorage.ts
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.