BTST

Media Plugin

Media library, uploads, folders, picker UI, and reusable image inputs

Full-stackReleased · Preview

Best for

Teams that need a shared media layer for product features while keeping files, metadata, and storage credentials under their control.

Upload, organize, register, and reuse media through a library that runs with your storage.

Real BTST Media library showing the checked-in product-proof image after upload, metadata registration, and search.
The proof fixture moves through the registered Media upload route into the searchable library, ready for picker and image-field reuse.

BTST supplies

  • Asset and folder data models with typed upload, registration, and library APIs
  • An SSR-aware media-library route with search, folders, and asset actions
  • Embeddable MediaPicker and ImageInputField components
  • Local, S3-compatible, and Vercel Blob storage adapter implementations

You supply

  • A database adapter with isolated transaction support for persistent writes
  • A configured storage adapter and its credentials or local upload directory
  • Allowed MIME types, size limits, URL prefixes, and authorization policy
  • The application routes and fields that embed the picker or image input

You own and customize

Asset metadata stays in your database and files stay in the storage you configure. You own limits, access policy, and embedding; the library view can be ejected.

Compatibility and dependencies

Maintained: Next.js 15+ App Router, React Router v7, TanStack Start.

Requires: An isolating Prisma, Drizzle, or Kysely database adapter for persistent writes; A configured media storage adapter.

External services: Optional S3-compatible storage or Vercel Blob when the adopter selects those adapters.

From registration to result

A semantic workflow, not a setup shortcut

  1. 1Choose storage

    Configure local, S3-compatible, or Vercel Blob storage in your backend.

  2. 2Upload or register

    Send a file through the matching protocol or register an allowed asset URL.

  3. 3Organize

    Search assets, maintain folders, and edit metadata in the library.

  4. 4Reuse

    Embed MediaPicker or ImageInputField wherever your application needs an asset.

Installation

Ensure you followed the general framework installation guide first.

Follow these steps to add the Media plugin to your BTST setup.

1. Add Plugin to Backend API

Import and register the media backend plugin in your stack.ts file:

lib/stack.ts
import { createBackendStack } from "@btst/stack/api"
import { mediaBackendPlugin } from "@btst/stack/plugins/media/api"
import { localAdapter } from "@btst/stack/plugins/media/api/adapters/local"

const { handler, dbSchema } = createBackendStack({
  basePath: "/api/data",
  plugins: {
    media: mediaBackendPlugin({
      storageAdapter: localAdapter(),
      maxFileSizeBytes: 10 * 1024 * 1024,
      allowedMimeTypes: ["image/*", "application/pdf"],
    }),
  },
  adapter: (db) => createPrismaAdapter(prisma, db, {
    provider: "postgresql",
    transaction: true,
  }),
})

export { handler, dbSchema }

The mediaBackendPlugin() requires a storageAdapter. BTST currently ships with three modes:

  • localAdapter() for local filesystem uploads and self-hosted setups
  • s3Adapter() for S3-compatible object storage using presigned uploads
  • vercelBlobAdapter() for direct uploads to Vercel Blob

Pick the backend storage adapter first, then make the client-side uploadMode match it. A mismatch between the two is the most common Media plugin integration mistake.

Media writes require an isolated database transaction so the final authorization/CAS check and mutation commit atomically. Set transaction: true for supported Prisma, Drizzle, and Kysely adapters. Existing manual setups that omit it fail closed with ATOMIC_TRANSACTION_REQUIRED; add the option before upgrading to v3. The memory adapter remains available for local, serialized development use.

2. Add Plugin to Client

Configure the shared client runtime once, then give Media only its own upload and lifecycle choices:

lib/stack-client.tsx
import { createClientStack } from "@btst/stack/client"
import { mediaClientPlugin } from "@btst/stack/plugins/media/client"
import { QueryClient } from "@tanstack/react-query"

const getBaseURL = (serverOrigin?: string) =>
  typeof window !== "undefined"
    ? process.env.NEXT_PUBLIC_SITE_URL || window.location.origin
    : serverOrigin ||
      process.env.BTST_SITE_URL ||
      process.env.BASE_URL ||
      "http://localhost:3000"

export const getStackClient = (
  queryClient: QueryClient,
  options?: {
    headers?: Headers
    identity?: { readonly id: string; readonly [key: string]: unknown }
    origin?: string
  },
) => {
  const baseURL = getBaseURL(options?.origin)

  return createClientStack({
    api: {
      baseURL,
      basePath: "/api/data",
      ...(options?.headers ? { headers: options.headers } : {}),
    },
    site: { baseURL, basePath: "/pages" },
    queryClient,
    plugins: {
      media: mediaClientPlugin({
        uploadMode: "direct",
        identityPartition: options?.identity,
      }),
    },
  })
}

uploadMode must match the backend storage adapter. identityPartition is the optional request identity snapshot used to align protected SSR cache keys with the browser's hydrated identity. API/site locations, request headers, and the React Query client belong to createClientStack() and are inherited by Media. Pass the request URL's origin as options.origin during SSR when a canonical BTST_SITE_URL or BASE_URL is not configured; browser stacks use the public site variable or window.location.origin.

The media client plugin registers the /media page route and prefetches the initial asset grid and folder tree during SSR. Create a request-specific stack with headers on the server, and a separate browser stack without request headers. The browser projection never serializes cookies or authorization headers.

3. Import Plugin CSS

Add the media plugin CSS to your global stylesheet:

app/globals.css
@import "@btst/stack/plugins/media/css";

This includes the built-in media library UI, picker layout, folder tree, upload states, and image previews.

4. Configure the Provider

Pass the resolved browser stack to StackProvider. No Media override block is required for the built-in experience:

app/pages/client-layout.tsx
"use client"

import { useMemo } from "react"
import { QueryClientProvider } from "@tanstack/react-query"
import { StackProvider } from "@btst/stack/context"
import { nextRouter } from "@btst/stack/next"
import { getOrCreateQueryClient } from "@/lib/query-client"
import { getStackClient, type StackClientOptions } from "@/lib/stack-client"

export function ClientLayout({ children, clientOrigins }: {
  children: React.ReactNode
  clientOrigins: StackClientOptions
}) {
  const queryClient = getOrCreateQueryClient()
  const stack = useMemo(
    () => getStackClient(queryClient, clientOrigins),
    [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient],
  )

  return (
    <QueryClientProvider client={queryClient}>
      <StackProvider
        stack={stack}
        router={nextRouter()}
      >
        {children}
      </StackProvider>
    </QueryClientProvider>
  )
}

Use the corresponding reactRouter() or tanstackRouter() adapter in those frameworks. overrides.media is reserved for genuine browser presentation and behavior choices such as imageCompression, onRouteRender, and onRouteError; it no longer accepts query clients, transport headers, or the upload mode. For a protected library, follow the Permissions example below, which supplies both auth={clientAuth} and initialIdentity; an identity snapshot is intentionally ignored when no auth adapter is present.

5. Generate and Apply Database Changes

The Media plugin adds database tables for assets and folders. Generate and apply your migrations:

npx @btst/cli generate
npx @btst/cli migrate

For more details on the CLI and all available options, see the CLI documentation.

Congratulations, You're Done!

Your media plugin is now configured and ready to use. Here is a quick reference of what you get out of the box:

Routes

RouteDescription
/pages/mediaFull media library UI with folders, uploads, URL tab, and asset browsing

Core API endpoints

MethodEndpointPurpose
GET/media/assetsList assets with filtering and pagination
POST/media/assetsRegister an existing uploaded asset URL
PATCH/media/assets/:idUpdate asset metadata
DELETE/media/assets/:idDelete an asset
GET/media/foldersList folders
POST/media/foldersCreate a folder
DELETE/media/folders/:idDelete a folder
POST/media/uploadDirect upload endpoint for local storage
POST/media/upload/tokenPresigned upload token endpoint for S3-compatible storage
POST/media/upload/vercel-blobUpload handler for Vercel Blob

Reusable UI pieces

ExportPurpose
MediaPickerEmbed the full media browser in your own forms and editors
ImageInputFieldDrop-in image field with preview, change, and remove actions
createMediaUploadConfig()Binds imperative uploads to the Media runtime resolved by the client stack
uploadAsset()Imperative upload helper for editors and non-React callbacks
useRegisterAssetForm()URL registration lifecycle with inline server field errors
useCreateFolderForm()Folder form lifecycle with notification and invalidation handling

Common Patterns

Imperative uploads for editor callbacks

When you need to upload an image outside React hooks, bind uploadAsset() to the Media runtime already resolved by your client stack:

app/pages/client-layout.tsx
import {
  createMediaUploadConfig,
  uploadAsset,
} from "@btst/stack/plugins/media/client"

const mediaClientConfig = createMediaUploadConfig(
  stack.provider.plugins.media,
)

const uploadImage = async (file: File) => {
  const asset = await uploadAsset(mediaClientConfig, { file })
  return asset.url
}

This preserves Media endpoint overrides, public browser headers, explicit credentials, and the configured upload mode. The generated apps use the same pattern to connect Blog, CMS, and Kanban image uploads to Media.

Embedding the picker in your own UI

Use MediaPicker when you want a compact "browse media" flow inside a custom form:

components/image-picker.tsx
import { MediaPicker } from "@btst/stack/plugins/media/client/components"
import { Button } from "@/components/ui/button"

export function ImagePicker({ onSelect }: { onSelect: (url: string) => void }) {
  return (
    <MediaPicker
      trigger={<Button type="button">Browse media</Button>}
      accept={["image/*"]}
      onSelect={(assets) => onSelect(assets[0]?.url ?? "")}
    />
  )
}

Using the built-in image field

Use ImageInputField when you want a simple image preview and replacement flow without building your own wrapper:

components/product-image-field.tsx
import { ImageInputField } from "@btst/stack/plugins/media/client/components"

export function ProductImageField({
  value,
  onChange,
}: {
  value: string
  onChange: (value: string) => void
}) {
  return <ImageInputField value={value} onChange={onChange} />
}

Building custom media forms

The form hooks use the same resource declaration as the built-in UI. They map server validation issues to fieldErrors, send success and non-field error feedback through the configured notify provider, and invalidate only the affected media list caches.

components/register-media-url.tsx
import { useState } from "react"
import { useRegisterAssetForm } from "@btst/stack/plugins/media/client/hooks"

export function RegisterMediaUrl({ folderId }: { folderId?: string }) {
  const form = useRegisterAssetForm({ folderId })
  const [url, setUrl] = useState("")

  return (
    <form onSubmit={(event) => {
      event.preventDefault()
      void form.submit({ url })
    }}>
      <input
        value={url}
        onChange={(event) => {
          setUrl(event.target.value)
          form.clearErrors()
        }}
      />
      {form.fieldErrors.url && <p>{form.fieldErrors.url}</p>}
      <button disabled={form.isSubmitting}>Add asset</button>
    </form>
  )
}

useCreateFolderForm({ parentId, onSuccess }) provides the same lifecycle for { name } folder forms.

Standalone library URL state

The built-in /media route keeps its current folder and search in the URL:

  • folder=<id> is pushed to history when the user changes folders.
  • q=<term> is replaced after a 300 ms debounce while the user types.
  • Empty/default values are removed from the query string.

The embedded MediaPicker intentionally keeps this state local, so opening a picker inside another form does not change the host page URL.

Permissions

Media publishes one browser-safe, runtime-schema-backed catalog from @btst/stack/plugins/media/permissions. Configure the rule once and bind the same definition to server and browser auth:

lib/authorization.ts
import { defineAuthorization } from "@btst/stack/authorization"
import { mediaPermissions } from "@btst/stack/plugins/media/permissions"
import { z } from "zod"

const identitySchema = z.object({
  id: z.string(),
  role: z.enum(["member", "admin"]),
  tenantIds: z.array(z.string()),
})

type Identity = z.infer<typeof identitySchema>

export const authorization = defineAuthorization({
  identity: identitySchema,
  permissions: [mediaPermissions] as const,
  rules: ({ media }) => {
    const belongsToTenant = (identity: Identity | null, tenantId?: string) =>
      identity?.role === "admin" ||
      Boolean(
        identity &&
        (!tenantId || identity.tenantIds.includes(tenantId)),
      )

    return [
      media.library.read.when(({ identity }) => identity !== null),
      media.asset.read.when(({ identity, facts }) =>
        belongsToTenant(identity, facts.tenantId),
      ),
      media.asset.upload.when(({ identity, facts }) =>
        // Vercel callbacks reach this rule only after provider verification.
        facts.phase === "callback" || belongsToTenant(identity, facts.tenantId),
      ),
      media.asset.update.when(({ identity, facts }) =>
        belongsToTenant(identity, facts.tenantId),
      ),
      media.asset.delete.when(({ identity, facts }) =>
        belongsToTenant(identity, facts.tenantId),
      ),
      media.folder.create.when(({ identity, facts }) =>
        belongsToTenant(identity, facts.tenantId),
      ),
      media.folder.delete.when(({ identity, facts }) =>
        belongsToTenant(identity, facts.tenantId),
      ),
    ]
  },
})
lib/stack.ts
import { createServerAuth } from "@btst/stack/authorization/server"
import { authorization } from "./authorization"

const serverAuth = createServerAuth({
  authorization,
  getIdentity: async ({ request }) => {
    const session = await getSession(request.headers)
    return session?.user ?? null
  },
})

export const appStack = createBackendStack({
  auth: serverAuth,
  plugins: {
    media: mediaBackendPlugin({
      storageAdapter: s3Adapter({ /* ... */ }),
      // Collection scope is a server concern, separate from allow/deny rules.
      resolveTenantId: async ({ headers }) =>
        (await getSession(headers))?.activeTenantId,
    }),
  },
  // adapter, basePath, ...
})
lib/stack-client.tsx
import { createClientAuth } from "@btst/stack/authorization/client"
import { authorization } from "./authorization"

export const clientAuth = createClientAuth({
  authorization,
  getIdentity: () => fetch("/api/session").then((response) => response.json()),
})

// Framework layout helpers resolve this once on the server. Pass the same
// snapshot to the Media SSR query partition.
const requestStack = getStackClient(queryClient, {
  headers: requestHeaders,
  identity: initialIdentity ?? undefined,
})
await requestStack.router.getRoute("/media")?.loader?.()

// Create a separate, header-free stack for the browser provider.
const browserStack = getStackClient(queryClient)

<StackProvider
  stack={browserStack}
  auth={clientAuth}
  initialIdentity={initialIdentity}
>
  {children}
</StackProvider>

The Next.js, React Router, and TanStack framework helpers hydrate initialIdentity; generated integrations forward that snapshot to Media. This avoids a second identity request before the protected asset and folder queries can render. Browser query keys also include the current identity, so a login, logout, user switch, failed resolution, or refetch cannot reuse another identity's response.

DescriptorAuthoritative server factsBuilt-in UI/operation
media.library.read()Server-side collection scope/media, folder lists, picker/library browse gate
media.asset.read(facts)assetId, folder, MIME, tenantAsset-list response, presentation, preview, copy, picker selection
media.asset.upload(facts)phase, folder, MIME, tenantDirect upload, S3/Vercel initialization, URL/provider finalization
media.asset.update(facts)Current asset plus target folderAsset metadata update
media.asset.delete(facts)Current asset/storage contextDestructive asset control and deletion
media.folder.create(facts)Current parent and tenantNew-folder controls and creation
media.folder.delete(facts)Every folder in the authoritative subtreeRecursive folder deletion

Browser facts are presentation hints. The browser can, for example, hide an admin button from identity.role, but the backend reloads the asset/folder, derives tenant and storage facts, evaluates the same descriptor, and performs a final compare-and-swap check before hooks, tokens, storage effects, or database mutation. Query scoping remains in resolveTenantId; it is not a boolean rule. An asset-list operation first checks library.read, derives the scoped rows on the server, and then checks asset.read for every asset before returning any asset data. A caller with only the collection rule receives no asset response; the rules never filter rows. Recursive folder deletion likewise evaluates folder.delete against the authoritative facts for the requested folder and every descendant before hooks or mutation. A rule that denies any member of the subtree denies the entire deletion. The backend also rejects malformed folder trees that cross the server-resolved tenant boundary.

Every transport uses the same operation lifecycle:

  1. Parse and validate input.
  2. Load authoritative facts and resolve the request identity.
  3. Evaluate the exact permission (or fail closed when its rule is missing).
  4. Recheck authoritative state, then run domain hooks and the storage/database work.
  5. Run after/error lifecycle hooks.

stack.forRequest(request).operations.media.* follows that entire lifecycle. stack.trusted.media.* is for trusted server jobs: it skips only user authorization and still runs validation, storage/domain hooks, cleanup, and ordering. Nested child-folder and asset writes inherit the tenant of their authoritatively loaded parent folder. Omitting auth leaves request operations permissive; configure server authorization to enforce the catalog.

Backend hooks are workflow/lifecycle hooks, not the authorization boundary. Do not trust browser ownership, tenant, MIME, status, URL, token, or storage facts inside a rule. Signed upload URLs and tokens are returned only by their exact upload operations, never by a collection-only permission.

Public behavior must be explicit in the exact catalog. For a public library, declare both media.library.read.allow() and media.asset.read.allow(); the collection rule alone does not release asset records. For a Vercel completion callback, allow only facts.phase === "callback"; Media verifies the provider signature and binds the callback to server-issued token context before that public rule is evaluated. A callback payload is never authoritative merely because the rule is public.

Translation and notifications

Built-in Media UI strings go through the StackProvider i18n provider under media.* keys. Action feedback goes through the notify provider instead of importing a toast library directly. With neither provider configured, the existing English copy and default notifications are used.

Multi-tenancy

The media plugin has first-class support for scoping assets and folders to a tenant — a user, organisation, or any other entity that should see only its own media. This is completely opt-in: if you do not configure resolveTenantId, the plugin behaves exactly as before.

How it works

Assets and folders each carry an optional tenantId column. The resolveTenantId hook is called on every HTTP request and returns the tenant identifier for that request. The plugin then:

  • Filters GET /media/assets and GET /media/folders to the resolved tenant.
  • Tags newly created assets and folders with the resolved tenant on POST /media/assets, POST /media/upload, POST /media/upload/token, and POST /media/folders.
  • Guards PATCH /media/assets/:id, DELETE /media/assets/:id, and DELETE /media/folders/:id — if the resolved tenantId does not match the resource's tenantId, the endpoint returns 404 (not 403, to avoid leaking that the ID exists).
  • Guards folderId assignments — supplying a folderId that belongs to a different tenant is rejected with 404 on all create and upload endpoints.

Returning null or undefined from resolveTenantId disables collection and record lookup scoping for that request, which is useful for super-admin routes. It does not allow a folder or asset to be reparented across tenant boundaries; use the raw migration helpers for an intentional cross-tenant move.

Configuration

lib/stack.ts
import { createBackendStack } from "@btst/stack/api"
import { mediaBackendPlugin } from "@btst/stack/plugins/media/api"
import { s3Adapter } from "@btst/stack/plugins/media/api/adapters/s3"
import { getSession } from "@/lib/auth"

const { handler, dbSchema } = createBackendStack({
  basePath: "/api/data",
  plugins: {
    media: mediaBackendPlugin({
      storageAdapter: s3Adapter({ /* ... */ }),
      resolveTenantId: async (context) => {
        const session = await getSession(context.headers as Headers)
        if (!session) throw new Error("Unauthorized")
        // Return the business profile ID as the tenant key.
        return session.user.activeProfileId ?? null
      },
    }),
  },
  adapter: (db) => createPrismaAdapter(prisma, db, {
    provider: "postgresql",
    transaction: true,
  }),
})

When resolveTenantId throws, the request is rejected. Use authorization rules for allow/deny decisions and resolveTenantId only to derive the server-side collection scope.

Get-or-create folders per tenant

A common pattern in trusted workflow automation is to find a tenant's root folder by name, creating it if it does not exist yet. The explicit tenant ID is an adapter-level concern, so use the retained standalone primitives instead of the removed raw business API:

import { createBackendStack } from "@btst/stack/api"
import {
  createFolder,
  getFolderByName,
} from "@btst/stack/plugins/media/api"

const app = createBackendStack({ /* ... */ })

async function getOrCreateProfileFolder(profileId: string) {
  const name = `blog-gen-${profileId}`
  const existing = await getFolderByName(app.adapter, name, null, profileId)

  if (existing) return existing

  return createFolder(app.adapter, {
    name,
    tenantId: profileId,
  })
}

getFolderByName(adapter, name, parentId?, tenantId?) scopes the lookup to a name, an optional parent folder, and an optional tenant. Pass null for parentId to search only root-level folders.

These lower-level primitives intentionally bypass operation authorization, validation, and lifecycle hooks; keep them inside trusted adapter-owned automation. Use app.trusted.media for ordinary trusted business calls that should retain the complete operation lifecycle.

API Reference

Backend (@btst/stack/plugins/media/api)

mediaBackendPlugin

Prop

Type

MediaBackendConfig

Choose your storage adapter and optional upload constraints:

Prop

Type

MediaBackendHooks

Customize backend behavior with optional lifecycle hooks for uploads, listing, folder management, and deletes:

Prop

Type

Example usage:

import { mediaBackendPlugin } from "@btst/stack/plugins/media/api"
import { localAdapter } from "@btst/stack/plugins/media/api/adapters/local"

mediaBackendPlugin({
  storageAdapter: localAdapter(),
  hooks: {
    onBeforeUpload: async (meta) => {
      if (await exceedsStorageQuota(meta.size ?? 0)) {
        throw new Error("Storage quota exceeded")
      }
    },
    onBeforeDeleteAsset: async (asset) => {
      if (asset.mimeType.startsWith("image/")) return
      throw new Error("Only image deletion is allowed here")
    },
  },
})

MediaApiContext

Prop

Type

StorageAdapter

Prop

Type

DirectStorageAdapter

Prop

Type

S3StorageAdapter

Prop

Type

VercelBlobStorageAdapter

Prop

Type

BTST's Vercel Blob adapter uses handleUpload from @vercel/blob/client for the /media/upload/vercel-blob token exchange route.

Client (@btst/stack/plugins/media/client)

mediaClientPlugin

Prop

Type

MediaClientConfig

The factory accepts only Media-specific choices. Shared transport, site, and React Query values come from createClientStack().

Prop

Type

MediaClientHooks

These hooks run around the media library SSR loader:

In v3, rename onLoadError to onErrorLoad. The error observer runs at most once per loader execution, and observer failures cannot make SSR reject.

Prop

Type

MediaLoaderContext

Prop

Type

MediaPluginOverrides

Configure media-specific overrides and route lifecycle hooks:

Prop

Type

MediaUploadMode

Prop

Type

MediaRouteContext

Prop

Type

uploadAsset

Prop

Type

MediaUploadClientConfig

Prop

Type

UploadAssetInput

Prop

Type

Components (@btst/stack/plugins/media/client/components)

MediaPicker

The full popover-based media browser with Browse, Upload, and URL tabs:

Prop

Type

MediaPickerProps

Prop

Type

ImageInputField

Use the built-in image preview field when you only need single-image selection:

Prop

Type

Hooks (@btst/stack/plugins/media/client/hooks)

The Media plugin exposes React Query-powered hooks for reading and mutating assets and folders:

useAssets

Prop

Type

useFolders

Prop

Type

useUploadAsset

Prop

Type

useRegisterAsset

Prop

Type

useDeleteAsset

Prop

Type

useCreateFolder

Prop

Type

useDeleteFolder

Prop

Type

useRegisterAssetForm

Prop

Type

UseRegisterAssetFormOptions

Prop

Type

useCreateFolderForm

Prop

Type

UseCreateFolderFormOptions

Prop

Type

Query keys (@btst/stack/plugins/media/query-keys)

mediaResources is the shared declaration used by client hooks and SSR query factories. Media list keys include the resolved service location so stacks that share a QueryClient cannot read or invalidate another Media endpoint's data:

  • Asset lists: ["mediaAssets", "list", discriminator, { identity?, endpoint: { baseURL, basePath } }]
  • Folder lists: ["mediaFolders", "list", "all" | "root" | parentId, { identity?, endpoint: { baseURL, basePath } }]

Pass the same browser-safe endpoint partition to resource factories and raw key builders:

import {
  MEDIA_QUERY_KEYS,
  type MediaEndpointPartition,
} from "@btst/stack/plugins/media/api"
import { createMediaQueryKeys } from "@btst/stack/plugins/media/query-keys"

const endpoint: MediaEndpointPartition = {
  baseURL: "https://media.example.com",
  basePath: "/api/data",
}

const queries = createMediaQueryKeys(client, headers)
const assetsQuery = queries.mediaAssets.list(
  { limit: 40 },
  undefined, // anonymous identity partition
  endpoint,
)
const foldersQuery = queries.mediaFolders.list(undefined, undefined, endpoint)

const assetsKey = MEDIA_QUERY_KEYS.assetsList(
  { limit: 40 },
  undefined,
  endpoint,
)

In v3, the endpoint is a required third list argument. When migrating manual SSR or SSG prefetch code, use the exact baseURL and basePath resolved for Media in the browser stack so hydration reuses the same cache. The partition deliberately accepts no headers, credentials, or API client; keep those transport values in the client/factory configuration. For authenticated data, pass the same identity partition used by MediaClientConfig.identityPartition as the second argument.

MediaEndpointPartition

Prop

Type

useFolders(undefined) lists all folders, while useFolders(null) lists only root folders. The two calls intentionally have distinct cache keys and HTTP semantics.

Asset search trims whitespace and accepts at most 200 characters. Because BTST adapters do not expose a portable substring operator, the backend searches within the newest 1,000 matching-scope assets before applying result pagination.

Asset uploads, URL registration, and deletes also refetch inactive asset-list variants. This keeps the Browse tab current when it remounts after an Upload or URL action.

The picker deliberately uses the infinite asset resource directly instead of the core useSelect helper: its folder tree, MIME filtering, thumbnails, multi-selection, and paginated grid need the richer Media-specific UI state.

Server-side Data Access

Use app.forRequest(request).operations.media for user-driven calls and app.trusted.media for explicitly trusted jobs. Both use the maintained operations; trusted calls skip user authorization but retain validation, tenant resolution, authoritative facts, storage/domain behavior, and hooks. app.raw.media contains only the tenant-free SSG prefetchForRoute helper.

Standalone getter and mutation exports remain lower-level adapter primitives for plugin internals and migrations whose caller owns authorization and lifecycle composition.

AssetListParams

Prop

Type

AssetListResult

Prop

Type

FolderListParams

Prop

Type

CreateAssetInput

Prop

Type

UpdateAssetInput

Prop

Type

CreateFolderInput

Prop

Type

Types

Asset

Prop

Type

Folder

Prop

Type

SerializedAsset

Prop

Type

SerializedFolder

Prop

Type

Static Site Generation (SSG)

The raw SSG helper can prefetch the initial library without a live HTTP server:

await appStack.raw.media.prefetchForRoute("library", queryClient, {
  baseURL: "https://app.example.com",
  basePath: "/api/data",
})

This seeds the same anonymous asset/folder keys used by the browser and strips tenant fields. The endpoint must exactly match the Media API location resolved by the browser stack; it scopes the cache and resolves local-adapter asset URLs, but accepts no headers or credentials. The helper deliberately performs no request authorization. Use it only when the generated page is intentionally public and protected at the deployment boundary; never put private or per-user Media data into a shared static build. Authenticated SSR should instead forward request headers, hydrate initialIdentity, and pass that identity as MediaClientConfig.identityPartition.

Shadcn Registry

The Media plugin UI layer is distributed as a shadcn registry block. Use the registry to eject and fully customize the page components while keeping all data-fetching and API logic from @btst/stack.

The registry installs only the view layer. Hooks and data-fetching continue to come from @btst/stack/plugins/media/client/hooks.

npx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-media.json
pnpx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-media.json
bunx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-media.json

This copies the media page components into src/components/btst/media/client/ in your project. All relative imports remain valid and you can edit the files freely while the plugin's data layer stays intact.

Using ejected components

After installing, wire your custom components into the plugin via the pageComponents option in your client plugin config:

lib/stack-client.tsx
import { mediaClientPlugin } from "@btst/stack/plugins/media/client"
import { LibraryPageComponent } from "@/components/btst/media/client/components/pages/library-page"

mediaClientPlugin({
  uploadMode: "direct",
  pageComponents: {
    library: LibraryPageComponent, // replaces the media library page
  },
})

The ejected library page reads the resolved API/site/query runtime and upload mode from the registered stack. Optional image compression and route lifecycle customization remain in the inferred media provider override.

On this page