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
| Name | Type | Description |
|---|---|---|
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 } | null | Percent complete during the PUT. |
error | Error | null | Set when status is error. |
reset | () => void | Clear back to idle. |
Lifecycle callbacks
Pass these in useUpload({ ... }) for analytics or toasts without depending on useEffect.
| Name | Type | Description |
|---|---|---|
onStart | (file: File) => void | Fires when upload() is invoked, before any network I/O. |
onComplete | (result: UploadResult) => void | Fires after the complete step resolves. |
onError | (err: Error) => void | Fires 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.