Tulip Logo IconTulip
Storage

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.

  1. The browser asks your app to presign an upload.
  2. Tulip creates a pending asset in storage_assets.
  3. The browser uploads the file directly to object storage.
  4. Your app confirms the upload.
  5. 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:

  • beforePresign
  • afterPresign
  • beforeConfirm
  • afterConfirm

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:

  • afterPresign runs before the file is fully confirmed
  • afterConfirm runs after the asset reaches ready
  • 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:

  • name
  • size
  • contentType

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 UploadZone when you want the fastest path for browser uploads.
  • Use uploadClient.upload() directly when your UI flow is custom.
  • Use Dropzone when you only need file selection, parsing, or local preview.

If the file bytes already live on the server, continue with /docs/storage/server.

On this page