Tulip Logo IconTulip
Storage

Setup

Wire Tulip Storage into your app with the schema, service, procedures, and file route.

Tulip Storage setup has four core pieces:

  • the storage_assets table in your database schema
  • a Storage instance on the server
  • storage procedures in your app router for browser uploads
  • a /api/storage/[[...rest]] route for serving files

Requirements

  • a Next.js app
  • Drizzle ORM configured for your database
  • @tulip-systems/core installed
  • an S3-compatible object storage bucket
  • auth in your app context if you want to serve private assets

1. Add the storage schema

Export the storage tables and enums from your app schema so Drizzle can include them in your database schema.

export {
  storageAssetStatusEnum,
  storageAssets,
  storageAssetVisibilityEnum,
} from "@tulip-systems/core/storage";

export * from "@/app/admin/products/_lib/schema";
export * from "@/app/admin/emails/_lib/schema";

The important part is storageAssets. That table is the canonical catalog for every uploaded file.

After adding the export, run your normal Drizzle migration flow.

pnpm db:push

2. Export your database schema type

Tulip uses your app schema type when creating the server context and storage procedures.

import type * as schema from "./schema";

export type DatabaseSchema = typeof schema;

3. Configure storage environment variables

Tulip currently ships with storageS3Adapter(), so you need S3-style credentials in your server environment.

See /docs/storage/adapters for adapter-specific configuration details and S3-compatible provider notes.

S3_BUCKET="my-app-uploads"
S3_ENDPOINT="https://<your-s3-endpoint>"
S3_ACCESS_KEY_ID="..."
S3_SECRET_ACCESS_KEY="..."

AWS S3 works, but S3-compatible providers work too because the adapter accepts a custom endpoint.

4. Create the storage service

Create a shared server instance that connects your database client to the storage adapter.

import { Storage, storageS3Adapter } from "@tulip-systems/core/storage/server";
import { env } from "@/env";
import { db } from "../db/init";

export const storage = Storage.init({
  db,
  adapter: storageS3Adapter({
    bucketName: env.S3_BUCKET,
    region: "auto",
    endpoint: env.S3_ENDPOINT,
    credentials: {
      accessKeyId: env.S3_ACCESS_KEY_ID,
      secretAccessKey: env.S3_SECRET_ACCESS_KEY,
    },
  }),
});

Storage is the main server API for presigning uploads, confirming them, reading objects, generating file URLs, and cleaning up assets.

This example uses the S3 adapter. For the adapter config itself, see /docs/storage/adapters.

If you want a different object key prefix than the default uploads, pass prefix when creating the service.

5. Add storage to your app context

Your app context needs storage so procedures and route handlers can access it.

import { createContext } from "@tulip-systems/core/config";
import { auth } from "./auth/init";
import { db } from "./db/init";
import * as schema from "./db/schema";
import type { DatabaseSchema } from "./db/types";
import { storage } from "./storage/init";

export const context = createContext<DatabaseSchema>({
  db,
  schema,
  auth,
  storage,
});

Private file requests use the auth session from this context before redirecting to a signed read URL.

6. Register the storage procedures

If you want browser uploads with createUploadClient() or UploadZone, add the storage procedures to your app router.

Create a storage router:

import { createStorageProcedures } from "@tulip-systems/core/storage/server";
import type { DatabaseSchema } from "../db/types";

export const storageRouter = createStorageProcedures<DatabaseSchema>();

Then register it in your app router:

import { storageRouter } from "../storage/router";

export const appRouter = {
  storage: storageRouter,
  products: productsRouter,
  emails: emailsRouter,
};

If you only use server-side methods such as storage.upload() or storage.getObject(), you can skip this step.

7. Create the upload client

If you want to upload files from the browser, create a shared upload client that calls your 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),
  },
});

This is the client you pass into components such as UploadZone, or use directly with uploadClient.prepareUpload() and uploadClient.upload().

If your app does not support browser uploads, you can skip this step and use the server Storage instance directly.

8. Mount the storage file route

Add the catch-all route so Tulip can serve files through your app.

import { createStorageRouteHandler } from "@tulip-systems/core/storage/server";
import { context } from "@/server/context";

export const { GET, POST, PUT, PATCH, DELETE } = createStorageRouteHandler({
  context,
});

Create that file at src/app/api/storage/[[...rest]]/route.ts.

This route is what makes these helpers work:

  • getAssetURL(assetId)
  • StorageImage
  • private and public file delivery through /api/storage/files/:id

9. Verify the setup

Once the schema is migrated and the route is mounted, start your app and request any UUID through the storage file route:

curl -i http://localhost:3000/api/storage/files/019d0051-2c0d-741e-9e3c-e5a5bc4d16a2

In a fresh app, a 404 Asset not found response is a good sign. It means:

  • the route is mounted
  • the storage service is available in context
  • the storage_assets table can be queried

If you get a server error instead, the usual causes are:

  • the storage schema was not migrated yet
  • storage was not added to the app context
  • your storage env vars are missing or invalid

What you have now

After this setup, your app is ready for:

  • browser uploads through storage procedures
  • client uploads through createUploadClient()
  • direct server-side uploads with Storage
  • file rendering with getAssetURL() and StorageImage
  • private file access through your app's auth session

The next step is choosing between the client API in /docs/storage/client and the server API in /docs/storage/server.

On this page