Storage adapter
S3 File Uploader, S3 File Viewer and View S3 Attachment all need three calls: sign an upload, resolve a key into a readable URL, and delete an object. They do not know how your back end spells those, so you supply them once through a StorageAdapter.
Installation
pnpm dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/kiota-storage.jsonnpx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/kiota-storage.jsonyarn dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/kiota-storage.jsonbunx --bun shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/kiota-storage.jsonInstalled automatically with any of the three components — you rarely add it by hand.
Wiring it up
'use client'
import { StorageProvider } from '@/lib/kiota-storage'
import { SignFile, GetFile, DeleteFile } from '@/lib/api/uploads-endpoints'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<StorageProvider adapter={{ signFile: SignFile, getFile: GetFile, deleteFile: DeleteFile }}>
{children}
</StorageProvider>
)
}That is usually the whole integration. Existing API clients drop in unchanged because the adapter methods are typed to return unknown and the payload is unwrapped for you.
The contract
interface StorageAdapter {
signFile(request: { fileName: string; contentType?: string; filePath?: string }): Promise<unknown>
getFile(fileKey: string): Promise<unknown>
deleteFile(fileKey: string): Promise<unknown>
proxyUploadEndpoint?: string
}signFile should resolve to something containing a presigned upload URL and the object key. getFile should resolve to something containing a readable URL, optionally with a content type.
Why unknown
Back ends wrap payloads differently, and pinning one shape would mean writing a shim per project. The resolvers peel { data } envelopes until they reach a level carrying a url or key, and read the first of several conventional names:
| Resolver | Reads |
|---|---|
resolveSignedUpload | url, putUrl, presignedUrl, uploadUrl — and key, fileKey, keyName, name |
resolveFile | url, getUrl, signedUrl, signed_url, downloadUrl — and contentType, mimeType, content_type |
A bare string is treated as the URL. If none of that fits, wrap your client in four lines and return whatever shape you like — the resolvers only need one of the names above.
Direct versus proxy uploads
S3FileUploader takes an uploadMode:
direct(default) PUTs the file straight to the presigned URL. One hop, no bytes through your server — but the bucket's CORS policy must allow browser PUTs from your origin.proxyPOSTs to your own route, which forwards to the presigned URL. Always works, at the cost of sending every byte twice. SetproxyUploadEndpointon the adapter if it is not/api/storage/proxy-upload.
Start with direct. Reach for proxy when uploads fail with a CORS error and the bucket policy is not yours to
change.
Per-instance overrides
Every storage component takes an adapter prop that wins over the provider. Useful when one screen reads from a different bucket, or in tests.
<S3FileViewer url={key} adapter={publicBucketAdapter} />Missing adapter
With neither a provider nor a prop, these components throw at mount with a message naming both fixes. That is deliberate: a silently dead uploader looks like a network fault and costs far more to track down than an error that says what is missing.