Setup
Wire Tulip Storage into your app with the schema, service, procedures, and file route.
Tulip Storage setup has four core pieces:
- the
storage_assetstable in your database schema - a
Storageinstance 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/coreinstalled- an S3-compatible object storage bucket
- auth in your app context if you want to serve private assets
1. Add the storage schema
Define the storage table in your app. The package expects this shape, but the app owns the concrete table so you can add tenant columns, indexes, metadata, or different base columns.
import { storageProviders } from "@tulip-systems/storage";
import { index, pgEnum, pgTable, unique } from "drizzle-orm/pg-core";
import { baseColumns } from "@/server/db/helpers";
export const storageAssetStatusEnum = pgEnum("storage_asset_status", ["pending", "ready", "error"]);
export const storageAssetVisibilityEnum = pgEnum("storage_asset_visibility", ["private", "public"]);
export const storageAssets = pgTable(
"storage_assets",
(t) => ({
...baseColumns,
provider: t.text({ enum: storageProviders }).notNull(),
bucket: t.text().notNull(),
key: t.text().notNull(),
status: storageAssetStatusEnum().notNull(),
visibility: storageAssetVisibilityEnum().notNull().default("private"),
size: t.integer().default(0),
contentType: t.varchar({ length: 255 }),
uploadId: t.uuid().notNull().defaultRandom(),
name: t.text(),
metadata: t.jsonb(),
etag: t.text(),
uploadedAt: t.timestamp().defaultNow().notNull(),
deletedAt: t.timestamp(),
}),
(t) => [
unique("storage_assets_provider_bucket_key_unique").on(t.provider, t.bucket, t.key),
index("storage_assets_status_idx").on(t.status),
index("storage_assets_bucket_key_idx").on(t.bucket, t.key),
],
);Export that schema from your app's Drizzle schema barrel so it is included in migrations.
export { storageAssetStatusEnum, storageAssets, storageAssetVisibilityEnum } from "../storage/schema";After adding the export, run your normal Drizzle migration flow.
pnpm db:push2. 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/main/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 } from "@tulip-systems/storage/server";
import { storageS3Adapter } from "@tulip-systems/storage/s3";
import { env } from "@/env";
import { db } from "../db/init";
import { storageAssets } from "./schema";
export const storage = Storage.init({
db,
tables: { storageAssets },
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/main/storage/adapters.
If you want a different object key prefix than the default uploads, pass prefix when creating the service.
5. Keep storage as a server singleton
Use the exported storage instance anywhere you implement storage procedures, route handlers, or backend features that need files.
Private file requests use your auth instance 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 { implement } from "@orpc/server";
import type { RPCContext } from "../router/init";
import { storageRouterContract } from "@tulip-systems/storage/server";
import { sessionMiddleware } from "../router/middleware";
import { storage } from "./init";
export const storageProcedure = implement(storageRouterContract)
.$context<RPCContext>()
.use(sessionMiddleware);
export const storageRouter = {
presign: storageProcedure.presign.handler(async ({ input }) => storage.presignUpload(input)),
confirm: storageProcedure.confirm.handler(async ({ input }) => storage.confirmUpload(input)),
deleteAsset: storageProcedure.deleteAsset.handler(async ({ input }) => storage.deleteAsset(input)),
deleteAssets: storageProcedure.deleteAssets.handler(async ({ input }) => storage.deleteAssets(input)),
restoreAsset: storageProcedure.restoreAsset.handler(async ({ input }) => storage.restoreAsset(input)),
restoreAssets: storageProcedure.restoreAssets.handler(async ({ input }) => storage.restoreAssets(input)),
purgeAsset: storageProcedure.purgeAsset.handler(async ({ input }) => storage.purgeAsset(input)),
purgeAssets: storageProcedure.purgeAssets.handler(async ({ input }) => storage.purgeAssets(input)),
};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/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/storage/server";
import { auth } from "@/server/auth/init";
import { storage } from "@/server/storage/init";
export const { GET, POST, PUT, PATCH, DELETE } = createStorageRouteHandler({ auth, storage });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-e5a5bc4d16a2In a fresh app, a 404 Asset not found response is a good sign. It means:
- the route is mounted
- the storage service can resolve assets
- the
storage_assetstable can be queried
If you get a server error instead, the usual causes are:
- the storage schema was not migrated yet
- the storage singleton was not configured correctly
- 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()andStorageImage - private file access through your app's auth session
The next step is choosing between the client API in /docs/main/storage/client and the server API in /docs/main/storage/server.