Client
Upload files from the browser with uploadClient, UploadZone, and Dropzone.
Tulip's client-side storage API is built around createUploadClient(). Use it when a user selects a file in the browser and you want a direct-to-storage upload flow that still creates and tracks assets in storage_assets.
When to use the client API
Use the client API when:
- a user drags files into your UI
- a user selects files in a form or command dialog
- you want direct browser uploads without sending file bodies through your app server
If the file already exists on the server or is generated by backend code, use /docs/storage/server instead.
How the client flow works
Client uploads use a presign -> PUT -> confirm flow.
- The browser asks your app to presign an upload.
- Tulip creates a
pendingasset instorage_assets. - The browser uploads the file directly to object storage.
- Your app confirms the upload.
- The asset becomes
ready.
This keeps large file bodies out of your app server while still letting Tulip track the file in your database.
Prerequisites
Before using the client API, make sure you already have:
- storage procedures registered in your app router
- a shared
uploadClient - storage wired into your app from
/docs/storage/setup
Create a shared upload client
Your app usually creates one shared upload client that calls the storage procedures.
import { createUploadClient } from "@tulip-systems/core/storage/client";
import { orpc } from "../router/client";
export const uploadClient = createUploadClient({
endpoints: {
presign: (input) => orpc.storage.presign.call(input),
confirm: (uploadId) => orpc.storage.confirm.call(uploadId),
deleteAsset: (id) => orpc.storage.deleteAsset.call(id),
deleteAssets: (ids) => orpc.storage.deleteAssets.call(ids),
restoreAsset: (id) => orpc.storage.restoreAsset.call(id),
restoreAssets: (ids) => orpc.storage.restoreAssets.call(ids),
purgeAsset: (id) => orpc.storage.purgeAsset.call(id),
purgeAssets: (ids) => orpc.storage.purgeAssets.call(ids),
},
});Use UploadZone
UploadZone is the highest-level upload component. It wraps the browser upload flow and works well for feature UIs such as image galleries and attachment lists.
"use client";
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { ImageGrid, UploadZone } from "@tulip-systems/core/storage/client";
import type { StorageAsset } from "@tulip-systems/core/storage";
import { orpc } from "@/server/router/client";
import { uploadClient } from "@/server/storage/client";
export function ProductImagesGrid({ productId }: { productId: string }) {
const queryClient = useQueryClient();
const queryKey = orpc.productImages.getAll.queryKey({ input: { productId } });
const imagesQuery = useSuspenseQuery(
orpc.productImages.getAll.queryOptions({ input: { productId } }),
);
return (
<UploadZone
variables={{}}
uploadClient={uploadClient}
uploadHooks={{
afterPresign: async (asset) => {
await orpc.productImages.create.call({ productId, assetId: asset.id });
},
}}
optimistic={{
add: (asset) => {
queryClient.setQueryData(queryKey, (prev) => [...(prev ?? []), asset as StorageAsset]);
},
remove: (ids) => {
queryClient.setQueryData(
queryKey,
(prev) => prev?.filter((node) => !ids.includes(node.id)) ?? [],
);
},
invalidate: () => queryClient.invalidateQueries({ queryKey }),
cancel: () => queryClient.cancelQueries({ queryKey }),
}}
>
<ImageGrid assets={imagesQuery.data} />
</UploadZone>
);
}This is the same pattern used in demo-spark for product images and email attachments.
Use upload hooks
Client uploads support hooks around the upload lifecycle:
beforePresignafterPresignbeforeConfirmafterConfirm
The most common use is creating or linking a domain record once the asset id exists.
uploadHooks={{
afterPresign: async (asset) => {
await orpc.emails.createAttachment.call({ emailId: data.id, assetId: asset.id });
},
}}Keep in mind:
afterPresignruns before the file is fully confirmedafterConfirmruns after the asset reachesready- if you create relation rows early, your app may need cleanup logic for failed uploads
Use the upload client directly
If you do not want UploadZone, you can call the upload client yourself.
const request = uploadClient.prepareUpload({
file,
visibility: "private",
metadata: { source: "product-form" },
});
const asset = await uploadClient.upload(request, {
afterConfirm: async (asset) => {
await orpc.productImages.create.call({ productId, assetId: asset.id });
},
});This is useful when uploads need to be triggered from a custom button, editor plugin, or form workflow.
prepareUpload() fills in the usual file-derived values for you:
namesizecontentType
Use Dropzone for local-only files
Sometimes you need file input behavior without uploading to object storage yet. In that case, use Dropzone instead of UploadZone.
import { Dropzone, DropzoneContent, DropzoneEmptyState } from "@tulip-systems/core/storage/client";
<Dropzone
src={files}
maxFiles={1}
onDrop={(files) => {
const file = files?.[0];
if (!file) return;
setFiles([file]);
}}
>
<DropzoneEmptyState />
<DropzoneContent />
</Dropzone>This is the pattern used in demo-spark for Excel import, where the file is parsed locally in the browser instead of being uploaded as a storage asset.
Cleanup behavior
If the upload fails after presigning, the upload client tries to delete the created asset record automatically.
That gives you a good default, but if you create related records in afterPresign, you may still need application-level cleanup for those relations.
Quick guidance
- Start with
UploadZonewhen you want the fastest path for browser uploads. - Use
uploadClient.upload()directly when your UI flow is custom. - Use
Dropzonewhen you only need file selection, parsing, or local preview.
If the file bytes already live on the server, continue with /docs/storage/server.