Tulip Logo IconTulip
Storage

Serving Files

Render images, generate download links, and serve private or public assets through your app.

Tulip serves storage assets through your app, not by exposing raw provider URLs directly in the UI.

The usual flow looks like this:

  1. Your UI links to /api/storage/files/:id.
  2. The storage route resolves the asset from storage_assets.
  3. Tulip checks whether the asset is public or private.
  4. Tulip generates a short-lived signed read URL.
  5. The request redirects to object storage.

This gives you stable app URLs while keeping provider-specific read URLs temporary.

The simplest way to serve a file in the UI is getAssetURL().

import { getAssetURL } from "@tulip-systems/core/storage";

const href = getAssetURL(asset.id);

That returns a stable app URL like:

/api/storage/files/019d0051-2c0d-741e-9e3c-e5a5bc4d16a2

Use it anywhere you need a file URL:

  • image sources
  • download links
  • file preview links
  • buttons that open an asset in a new tab

Inline display vs attachment download

Tulip supports two file dispositions:

  • inline
  • attachment

Use attachment when the file should download instead of rendering inline in the browser.

import { getAssetURL } from "@tulip-systems/core/storage";

const downloadHref = getAssetURL(asset.id, { disposition: "attachment" });

That produces:

/api/storage/files/<asset-id>?disposition=attachment

This pattern is used in demo-spark for email attachments.

<Link href={getAssetURL(asset.id, { disposition: "attachment" })} target="_blank">
  {asset.name}
</Link>

Render images

For images, you can either use getAssetURL() with next/image or use StorageImage.

Use StorageImage

StorageImage is the simplest option because it already provides the Tulip image loader.

import { StorageImage } from "@tulip-systems/core/storage/client";
import { getAssetURL } from "@tulip-systems/core/storage";

<StorageImage
  src={getAssetURL(asset.id)}
  alt={asset.name ?? "Uploaded image"}
  width={320}
  height={240}
/>

Use next/image directly

If you need full control, use next/image with imageLoader.

import Image from "next/image";
import { getAssetURL, imageLoader } from "@tulip-systems/core/storage";

<Image
  src={getAssetURL(asset.id)}
  alt="Product image"
  width={100}
  height={100}
  loader={imageLoader}
/>

That is the pattern used in demo-spark for product image cards.

What the route handler does

createStorageRouteHandler() powers the /api/storage/[[...rest]] endpoint.

Right now, the file-serving behavior is:

  • it supports GET /api/storage/files/:id
  • it loads the asset by id
  • it returns 404 if the asset does not exist
  • it checks auth for private assets
  • it generates a signed read URL with context.storage.getObjectURL()
  • it redirects with 307 to the provider URL

That means your UI should treat the app URL as the canonical file URL, even though the final file bytes come from object storage.

Public and private assets

Each asset has a visibility value:

  • public
  • private

Tulip enforces the basic rule in the storage route:

  • public assets can be requested without a session
  • private assets require an authenticated session

This is enough for many internal apps, but it is intentionally not full domain authorization.

For example, Tulip does not automatically know whether a signed-in user is allowed to read a specific invoice attachment or project image. If you need finer-grained access control, keep those rules in your app.

Why not use raw S3 URLs?

Using app URLs through getAssetURL() has a few benefits:

  • your UI stays provider-agnostic
  • private access can run through your app session
  • links stay stable even if provider details change
  • signed provider URLs remain short-lived

In most cases, you should store the asset id in your own records and generate the URL at render time.

Common patterns

Open a file in a new tab

window.open(getAssetURL(asset.id), "_blank");

Force a download

window.open(getAssetURL(asset.id, { disposition: "attachment" }), "_blank");

Render a product image

<StorageImage
  src={getAssetURL(image.assetId)}
  alt="Product image"
  width={100}
  height={100}
/>

Things to keep in mind

  • getAssetURL() builds app-relative URLs, not provider URLs.
  • imageLoader resolves those URLs correctly for next/image.
  • The current built-in route support is only GET /api/storage/files/:id.
  • A private asset without a valid session returns 401 instead of redirecting.
  • A missing asset returns 404.

Once files can be uploaded and served, the next step is usually attaching them cleanly to your own domain records.

On this page