Next.js

App Router adapter — one route handler on the server, one hook on the client.

Route handler

createUploadRoute returns a single POST handler that serves both the presign and complete steps. The client SDK distinguishes them by an action field in the request body — you don't need two routes.

This route signs upload URLs with your API key. Without an authorization check it is an unauthenticated upload proxy that anyone on the internet can use at your expense, so onPresign is mandatory — omitting it is a type error, and an untyped JS caller gets a 403 at runtime. Throw from the hook to deny.
app/api/upload/route.ts
import { StorageClient } from 'sonuslab-storage/server'
import { createUploadRoute } from 'sonuslab-storage/react'

const storage = new StorageClient({
  apiKey: process.env.SONUSLAB_STORAGE_API_KEY!,
})

// onPresign is REQUIRED — this route mints presigned R2 URLs signed with your
// API key. Without an auth check it's an open upload proxy billed to you.
export const { POST } = createUploadRoute({
  storage,
  onPresign: async (req) => {
    const session = await auth(req)
    if (!session) throw new Response('Unauthorized', { status: 401 })
  },
})

Client component

Both presignEndpoint and completeEndpoint point at the same route.

components/Uploader.tsx
'use client'

import { useUpload } from 'sonuslab-storage/react'

export function Uploader() {
  const { upload, status, progress, error, reset } = useUpload({
    presignEndpoint: '/api/upload',
    completeEndpoint: '/api/upload',
  })

  return (
    <div>
      <input
        type="file"
        disabled={status === 'uploading'}
        onChange={(e) => {
          const file = e.target.files?.[0]
          if (file) upload(file)
        }}
      />
      {status === 'uploading' && <p>{progress}%</p>}
      {status === 'error' && (
        <>
          <p>{error?.message}</p>
          <button onClick={reset}>Retry</button>
        </>
      )}
    </div>
  )
}

Hook return shape

NameTypeDescription
upload(file: File, metadata?: Record<string, unknown>) => Promise<UploadResult>Start the upload. Resolves with fileId + key.
status'idle' | 'uploading' | 'complete' | 'error'Current state.
progress{ loaded, total, percent } | nullPercent complete during the PUT.
errorError | nullSet when status is error.
reset() => voidClear back to idle.

Lifecycle callbacks

Pass these in useUpload({ ... }) for analytics or toasts without depending on useEffect.

NameTypeDescription
onStart(file: File) => voidFires when upload() is invoked, before any network I/O.
onComplete(result: UploadResult) => voidFires after the complete step resolves.
onError(err: Error) => voidFires when the upload rejects (after status becomes error).

Auth

The onPresign hook authenticates the user and injects metadata before the SDK mints the upload URL. Throwing anything — a Response, an Error, whatever — denies the request with a 403. Return { metadata } to merge extra fields into what's sent to storage-api.

app/api/upload/route.ts
export const { POST } = createUploadRoute({
  storage,
  onPresign: async (req, body) => {
    const user = await getUserFromCookie(req)
    if (!user) throw new Response('Unauthorized', { status: 401 })
    return { metadata: { userId: user.id } }
  },
})
sonuslab-storage/server must only be imported in route handlers, server actions, or other server-only files. The API key must never reach the client bundle.
Copyright © 2026