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
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: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/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-e5a5bc4d16a2In 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_assetstable can be queried
If you get a server error instead, the usual causes are:
- the storage schema was not migrated yet
storagewas 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()andStorageImage - 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.