Silo Docs
Server

Server

Typed file routes, middleware, upload expectations, and callback dispatch.

@silo-storage/sdk-server is the framework-agnostic router layer for Silo uploads.

It gives you an UploadThing-style API for defining file routes while keeping the runtime portable.

What it provides

  • createSiloUpload
  • FileRouter
  • registerRouteUpload
  • prepareRouteUpload
  • handleUploadCallback
  • extractRouterConfig

Use this package when you want your upload rules, middleware, and completion logic to live in one place without coupling them to a specific HTTP adapter.

Install

npm install @silo-storage/sdk-server @silo-storage/sdk-core

If you are wiring the router into a framework adapter such as Next.js, install that package too and continue with Next.

Define a file router

Start by creating a typed route builder with the request and context types you want to use on the server.

src/upload.ts
import type { FileRouter } from "@silo-storage/sdk-server";
import { createSiloUpload } from "@silo-storage/sdk-server";
import { z } from "zod";

export interface UploadContext {
  userId: string;
  plan: "free" | "pro";
}

const f = createSiloUpload<Request, UploadContext>();

export const fileRouter = {
  avatar: f(
    z.object({
      folder: z.enum(["avatars", "attachments"]).default("avatars"),
      public: z.boolean().optional(),
      kind: z.enum(["image", "binary"]).default("image"),
    }),
  )
    .middleware(async ({ req, context, input }) => {
      const session = req.headers.get("authorization");
      if (!session || !context.userId) {
        throw new Error("Unauthorized");
      }

      return {
        userId: context.userId,
        plan: context.plan,
        folder: input.folder,
        kind: input.kind,
      };
    })
    .expects(({ input, plan }) =>
      input.kind === "binary"
        ? [
            {
              mimeTypes: ["application/xyz", "application/abc"],
              maxFileCount: plan === "pro" ? 4 : 2,
              maxFileSize: "16MB",
            },
          ]
        : {
            image: {
              maxFileCount: 4,
              maxFileSize: "8MB",
              mimeTypes:
                input.folder === "avatars"
                  ? ["image/png", "image/jpeg"]
                  : ["image/png", "image/jpeg", "image/webp"],
            },
          },
    )
    .public(({ input }) => input.public ?? false)
    .serveImage(({ input }) => input.folder === "avatars")
    .expires(({ input }) =>
      input.folder === "avatars" ? "30 days" : "7 days",
    )
    .onUploadComplete(async ({ metadata, file, core }) => {
      return {
        uploadedBy: metadata.userId,
        plan: metadata.plan,
        fileId: file.fileId,
        fileKeyId: file.fileKeyId,
        expiresAt: file.expiresAt,
        imageUrl: await core.generateImageUrl(file),
      };
    }),
} satisfies FileRouter<Request, UploadContext>;

How the route builder works

Each call to f(...) starts a route definition:

  • f() starts a route without app-defined input
  • f(schema) attaches route-local input validation using Standard Schema v1
  • .middleware(...) runs before registration and can reject the upload
  • .expects(...) defines the file expectations for the route
  • .public(...), .serveImage(...), and .expires(...) configure per-route behavior
  • .onUploadComplete(...) runs after Silo sends the signed callback for a completed upload and receives the route core instance

onUploadComplete(...) is a per-file callback.

If a route allows multiple files and the client uploads 4 files, Silo dispatches 4 completion callbacks and your route's onUploadComplete(...) handler runs 4 separate times, once for each file.

The builder is fully typed. The context type comes from createSiloUpload<Request, UploadContext>(), and the route input type comes from the schema you pass to f(schema).

f(schema) is optional.

Routes that do not need app-defined input can call f() directly.

You can pass both static values and async resolvers to .expects(...), .public(...), .serveImage(...), and .expires(...).

What middleware does

Middleware runs before upload registration and returns metadata for the file route.

That metadata is:

  • persisted during registration
  • stored in Silo-owned callback metadata
  • passed back into onUploadComplete

This lets you attach application state such as the current user ID or validated route input without asking the browser to report completion on its own.

Middleware must return a plain object. That object becomes file metadata for the upload and is restored for the matching route when handleUploadCallback(...) dispatches onUploadComplete(...).

When a route defines f(schema), middleware, expects resolvers, and route option resolvers receive the parsed schema output as input, including defaults and transforms from libraries like Zod.

File expectations

File expectations are enforced on the upload endpoint.

Use .expects(...) to define the files a route can accept.

Object shorthand

Use the object form for broad buckets and exact keyed MIME types:

f(
  z.object({
    kind: z.enum(["image", "pdf"]).default("image"),
  }),
)
  .middleware(async ({ context }) => ({ userId: context.userId }))
  .expects(({ input }) => ({
    image: {
      maxFileCount: 4,
      maxFileSize: "8MB",
      mimeTypes: ["image/png", "image/jpeg"],
    },
    "application/pdf": {
      maxFileCount: input.kind === "pdf" ? 4 : 1,
      maxFileSize: "16MB",
    },
  }))

This form is concise when the route maps naturally to broad file groups like image or to exact keyed MIME types like application/pdf.

Array buckets

Use the array form when several arbitrary MIME types should share the same pool:

f()
  .middleware(async ({ context }) => ({ userId: context.userId }))
  .expects([
    {
      mimeTypes: ["application/xyz", "application/abc"],
      maxFileCount: 4,
      maxFileSize: "16MB",
    },
  ])

That is the form to use when one shared maxFileCount applies to a custom MIME list.

You can also combine a broad type with a narrowed MIME list:

f()
  .middleware(async ({ context }) => ({ userId: context.userId }))
  .expects([
    {
      type: "image",
      mimeTypes: ["image/png", "image/jpeg"],
      maxFileCount: 4,
      maxFileSize: "8MB",
    },
  ])

Defaults

Routes that omit .expects(...) behave like a single-file blob route.

Register uploads manually

If you are not using a framework adapter, call registerRouteUpload(...) directly from your own HTTP endpoint.

src/http/register-upload.ts
import { createSiloCoreFromToken } from "@silo-storage/sdk-core";
import { registerRouteUpload } from "@silo-storage/sdk-server";

import { fileRouter } from "../upload";

const core = createSiloCoreFromToken({
  url: process.env.SILO_URL!,
  token: process.env.SILO_TOKEN!,
  cdnHost: process.env.SILO_CDN_HOST!,
});

export async function registerUpload(request: Request, context: { userId: string; plan: "free" | "pro" }) {
  const result = await registerRouteUpload({
    core,
    router: fileRouter,
    routeSlug: "avatar",
    req: request,
    context,
    input: { folder: "avatars", kind: "image" },
    callbackUrl: "https://app.example.com/api/upload/callback",
    files: [
      {
        fileName: "avatar.png",
        size: 1_250_000,
        mimeType: "image/png",
      },
    ],
  });

  return result.registerResult;
}

registerRouteUpload(...) does four important things for you:

  1. resolves the route from routeSlug
  2. runs middleware and captures its return value
  3. resolves .expects(...) and route options such as public, serveImage, and expires
  4. writes internal callback metadata so completion can be dispatched back to the same route later

By default registration uses the core uploadStrategy: "server" flow. Pass uploadStrategy: "self" only when you explicitly want local signing behavior for that call.

You can also pass uploadMethod: "put" when the client should receive a direct signed PUT URL instead of the default resumable TUS URL.

Prepare a single upload

Use prepareRouteUpload(...) when you want the convenience of single-file registration plus a response shaped like core.prepareUpload(...).

src/http/prepare-upload.ts
import { createSiloCoreFromToken } from "@silo-storage/sdk-core";
import { prepareRouteUpload } from "@silo-storage/sdk-server";

import { fileRouter } from "../upload";

const core = createSiloCoreFromToken({
  url: process.env.SILO_URL!,
  token: process.env.SILO_TOKEN!,
  cdnHost: process.env.SILO_CDN_HOST!,
});

export async function prepareUpload(request: Request, context: { userId: string; plan: "free" | "pro" }) {
  const result = await prepareRouteUpload({
    core,
    router: fileRouter,
    routeSlug: "avatar",
    req: request,
    context,
    input: { folder: "avatars", kind: "image" },
    file: {
      fileName: "avatar.png",
      size: 1_250_000,
      mimeType: "image/png",
    },
    uploadMethod: "put",
  });

  return result.prepareResult;
}

Reach for registerRouteUpload(...) when you are handling a batch of files. Reach for prepareRouteUpload(...) when your transport or UI works better with one file at a time.

Handle upload callbacks

Your framework adapter should handle this for you. You should only need to call this if you are handling the callback yourself.

When Silo sends a signed callback, use handleUploadCallback(...) to verify it and dispatch the matching route's onUploadComplete(...).

src/http/upload-callback.ts
import { createSiloCoreFromToken, parseSiloToken } from "@silo-storage/sdk-core";
import { handleUploadCallback } from "@silo-storage/sdk-server";

import { fileRouter } from "../upload";
import type { UploadContext } from "../upload";

const core = createSiloCoreFromToken({
  url: process.env.SILO_URL!,
  token: process.env.SILO_TOKEN!,
  cdnHost: process.env.SILO_CDN_HOST!,
});
const signingSecret = parseSiloToken(process.env.SILO_TOKEN!).signingSecret;

export async function handleCallback(request: Request, context: UploadContext) {
  const result = await handleUploadCallback({
    router: fileRouter,
    core,
    request,
    signingSecret,
    context,
  });

  if (result.status === "ignored") {
    return new Response(null, { status: 204 });
  }

  return Response.json({
    routeSlug: result.routeSlug,
    callbackMetadata: result.callbackMetadata,
    onUploadCompleteResult: result.onUploadCompleteResult,
  });
}

handleUploadCallback(...) verifies the signature first, then reads the internal callback envelope to recover the original route slug and middleware metadata. That is what lets the library call the correct onUploadComplete(...) handler without trusting the browser to report success.

callbackMetadata.__silo is reserved for sdk-server internals and should be treated as library-owned state.

Expose safe router config to clients

extractRouterConfig(...) returns only the client-safe route config shape for routes with statically known expectations. This is useful when a UI layer wants to know route constraints without learning your middleware or server-only logic.

src/router-config.ts
import { extractRouterConfig } from "@silo-storage/sdk-server";

import { fileRouter } from "./upload";

export const routerConfig = extractRouterConfig(fileRouter);

This is commonly paired with a client SDK or server-rendered app shell so the browser can discover file size and count limits up front.

API reference

For generated type tables covering the route builder and callback helpers, see API Reference.

On this page