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:
- Your UI links to
/api/storage/files/:id. - The storage route resolves the asset from
storage_assets. - Tulip checks whether the asset is public or private.
- Tulip generates a short-lived signed read URL.
- The request redirects to object storage.
This gives you stable app URLs while keeping provider-specific read URLs temporary.
Use getAssetURL() for links
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-e5a5bc4d16a2Use 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:
inlineattachment
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=attachmentThis 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
404if the asset does not exist - it checks auth for private assets
- it generates a signed read URL with
context.storage.getObjectURL() - it redirects with
307to 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:
publicprivate
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.imageLoaderresolves those URLs correctly fornext/image.- The current built-in route support is only
GET /api/storage/files/:id. - A private asset without a valid session returns
401instead 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.