Quickstart · SDKs

Image uploads in five minutes.

Define an upload router on your server, mount it at one URL, drop an <UploadButton> into a page and render the result with <Img2siteImage>. Bytes go straight from the browser to storage; your server only authorizes the upload and hears about the result. The browser never sees your API key.

1. Install

@img2site/sdk-server runs in Node (router, REST client, webhook verification). @img2site/sdk-react holds the hook and components and re-exports imageUrl from @img2site/sdk-client.

Create a project and an API key in the dashboard. The router's calls need the files:write and files:read scopes; a key without scopes has full project access.

Terminal

npm install @img2site/sdk-server @img2site/sdk-react
# @img2site/sdk-react brings @img2site/sdk-client (the framework-free browser uploader).
# Node 20 or newer; the packages are ESM only.

Environment

# .env.local (server only; never expose these to the browser)
IMG2SITE_API_KEY=i2s_live_…     # a project API key (files:write and files:read, or no scopes = full access)
IMG2SITE_PROJECT=my-project     # optional: the key's project slug (looked up from the key when unset)

2. Configure the client

createClient needs the key. The key's project slug (project) is optional: without it the client looks it up once with GET /v1/key. It talks to https://api.img2.site unless you pass baseUrl. Keep these values in server-only environment variables.

3. Define a router

Each key of createRouter is a route. route(constraints) accepts image (every image/* type) or an exact type such as image/png, each with maxSize (a byte count or "8MB", "512KB"; binary units) and maxCount. maxCount is advisory: the router is stateless and each prepare is one file.

.middleware(fn) receives { req, route, input, file } and returns metadata; throw UploadRejectedError to refuse with your own status and code. .input(schema) validates a client-supplied input first (anything with parse(), such as a zod schema). .onUploadComplete(fn) receives { file, metadata, upload }.

onUploadComplete runs only when the file is ready, and can run again if the browser repeats the complete call, so make it idempotent. Use webhooks when you need at-least-once delivery that does not depend on the browser.

lib/img2site.ts

// lib/img2site.ts (server only)
import { createClient, createRouter, route, UploadRejectedError } from "@img2site/sdk-server";
import { getCurrentUser, saveAvatar } from "./your-app"; // your own auth and persistence

export const img2site = createClient({
  apiKey: process.env.IMG2SITE_API_KEY!,
  project: process.env.IMG2SITE_PROJECT, // optional; resolved once via GET /v1/key when omitted
});

export const uploadRouter = createRouter({
  avatar: route({ image: { maxSize: "8MB", maxCount: 1 } })
    // Runs on every prepare, before any upload session exists.
    .middleware(async ({ req, file }) => {
      const user = await getCurrentUser(req);
      if (!user) throw new UploadRejectedError("sign in to upload", { status: 401, code: "unauthorized" });
      return { userId: user.id }; // carried (signed, not encrypted) to onUploadComplete
    })
    // Runs once the upload is ready; the return value reaches the browser as server_data.
    .onUploadComplete(async ({ file, metadata }) => {
      await saveAvatar(metadata.userId, file.file_slug);
      return { avatar: file.file_slug };
    }),
});

export const uploadHandler = uploadRouter.handler({ client: img2site });

Next.js App Router

// app/api/img2site/route.ts (Next.js App Router, Node.js runtime)
import { toNext } from "@img2site/sdk-server";
import { uploadHandler } from "@/lib/img2site";

export const { POST } = toNext(uploadHandler);

4. Mount the handler

router.handler({ client }) returns a framework-agnostic (Request) => Promise<Response> that answers POST ?action=prepare and POST ?action=complete. The adapters are thin wrappers. The server SDK uses node:crypto, so use the Node.js runtime rather than an edge runtime.

Express, Hono, Remix

toExpress also accepts a request whose body a parser such as express.json() already read.

Other frameworks

import { toExpress, toHono, toRemix } from "@img2site/sdk-server";

// Express (works with or without express.json() in front of it)
app.post("/api/img2site", toExpress(uploadHandler));

// Hono
app.post("/api/img2site", toHono(uploadHandler));

// Remix / React Router 7+: app/routes/api.img2site.ts
export const action = toRemix(uploadHandler);

5. Upload and render

<UploadButton> needs endpoint (there is no default path) and takes route, onComplete(results), onError(error), accept, multiple, className, disabled and children (a node or a function of { state, progress, file }). With multiple the files upload one after another and onComplete fires once with every result. The button is unstyled and exposes data-state and aria-busy.

A result is { upload_id, file_slug, status, url, width, height, server_data? }. When processing takes longer than the router's 30-second poll, status is still processing and url is null; the file becomes ready shortly after.

<Img2siteImage host slug chain? kind? alt /> renders an <img> for an unsigned delivery URL; the chain uses the delivery URL grammar. Protected projects need a server-signed URL instead (img2site.signedUrl()).

app/profile/avatar-upload.tsx

// app/profile/avatar-upload.tsx
"use client";
import { useState } from "react";
import { Img2siteImage, UploadButton } from "@img2site/sdk-react";

export function AvatarUpload() {
  const [slug, setSlug] = useState<string | null>(null);
  return (
    <div>
      <UploadButton
        endpoint="/api/img2site"
        route="avatar"
        accept="image/*"
        onComplete={(results) => setSlug(results[0]?.file_slug ?? null)}
        onError={(error) => console.error(error.code, error.message)}
      />
      {slug && (
        <Img2siteImage
          host="my-project.i2scd.net"
          slug={slug}
          chain="resize/256x256/smart/format/webp"
          alt="Your avatar"
          width={256}
          height={256}
        />
      )}
    </div>
  );
}

How the upload works

Upload steps and where the bytes go
StepWhoWhat happens
prepareBrowser → your handlerThe handler validates the route, type and size, runs middleware, creates an upload session with your API key and returns the session plus an upload token.
PUTBrowser → storageThe browser sends the bytes to the presigned storage URL of the session; your server never relays them. Only in proxy mode (or the proxy fallback) do they go through the img2site API's upload proxy.
completeBrowser → your handlerThe handler verifies the token, completes the session, polls it for up to 30 seconds and runs onUploadComplete once the file is ready.
publishimg2siteThe file is sanitized, stored and published on your delivery host. A upload.ready webhook goes out if the project has a callback URL.

Upload tokens

The token is an HMAC keyed with a hash of your API key over the upload id, the session expiry and a state segment carrying the route name and your middleware metadata. The complete call needs no API key, and the browser cannot change the route, the metadata or the upload id. The state is base64url JSON, readable but tamper-proof, so do not put secrets in middleware metadata. Tokens expire with the session and stop working when you rotate the API key.

Multipart above 16 MiB

Files up to 16 MiB are one PUT. Larger files use S3 multipart: 8 MiB parts by default (the partSize option, at least 5 MiB; the session's part size wins), three parts at a time (concurrency), each retried up to three times on 5xx, 429 or network errors (maxRetries). Progress advances as each part is acknowledged. The storage bucket must expose the ETag header to the browser; otherwise the upload fails with etag_missing.

Pause, resume, abort

pause() stops in-flight part uploads and resume() sends only the parts that were not acknowledged. When a PUT or complete fails transiently on a live session (a network error, 408, 429 or 5xx), the error carries error.resume(), which continues the same session and re-sends only the missing parts. Permanent refusals (other 4xx, such as 422 unsupported_media_type) have no resume(): fix the file and upload again. A multipart session lives 12 hours, so it also survives a page reload or a device that was off for hours: store the handle onSession gives you (it is a credential for that upload: keep it per user and clear it on logout), then call upload(file, { resume: handle }) with the same file to send only the missing parts. Expired part URLs are re-signed automatically. abort() ends in an error with code aborted and also cancels the session on the server, so it cannot be resumed. detach() stops uploads in the page only (code detached) and keeps the session resumable. Unmounting a component that uses useUpload detaches an upload started with onSession or resume, and aborts any other, since nothing could resume it.

Proxy fallback

Some networks block direct uploads to the storage host. When you leave mode unset and a single-PUT upload fails once with a network or CORS error, the client prepares a fresh session in proxy mode and sends the bytes to that session's proxy put_url instead. Pass mode: "proxy" to uploader.upload() to use the proxy from the start. Multipart uploads are always direct.

Custom UI with useUpload

useUpload({ endpoint, route? }) returns upload, progress ({ sent, total } or null), state (idle, uploading, paused, done, error), result, error, pause, resume, abort and reset. It also accepts the uploader options (partSize, concurrency, headers, maxRetries, retryBaseDelayMs, fetch).

useUpload

"use client";
import { useUpload } from "@img2site/sdk-react";

export function CustomUploader() {
  const { upload, state, progress, result, error, pause, resume, abort, reset } = useUpload({
    endpoint: "/api/img2site",
    route: "avatar",
  });
  return (
    <div data-state={state}>
      <input type="file" accept="image/*" onChange={(e) => {
        const file = e.currentTarget.files?.[0];
        if (file) upload(file).catch(() => {}); // the error is also in `error`
      }} />
      {progress && <progress value={progress.sent} max={progress.total} />}
      {state === "uploading" && <button onClick={pause}>Pause</button>}
      {(state === "paused" || (state === "error" && error?.resume)) && <button onClick={resume}>Resume</button>}
      {(state === "uploading" || state === "paused") && <button onClick={abort}>Cancel</button>}
      {state === "done" && <p>Uploaded {result?.file_slug}</p>}
      {state === "error" && <p>{error?.code}: {error?.message} <button onClick={reset}>Start over</button></p>}
    </div>
  );
}

Without React (@img2site/sdk-client)

import { createUploader, imageUrl, Img2siteUploadError } from "@img2site/sdk-client";

const uploader = createUploader({ endpoint: "/api/img2site" });

async function send(file: File) {
  try {
    const result = await uploader.upload(file, {
      route: "avatar",
      onProgress: ({ loaded, total }) => console.log(`${Math.round((loaded / total) * 100)}%`),
    });
    return imageUrl({ host: "my-project.i2scd.net", slug: result.file_slug, chain: "resize/256x256/smart" });
  } catch (error) {
    // A failed PUT or complete of a live session can continue where it stopped.
    if (error instanceof Img2siteUploadError && error.resume) return error.resume();
    throw error;
  }
}

Without React

createUploader({ endpoint }) returns upload(file, options), pause(), resume(), abort() and detach(). Per-upload options are route, slug, input, onProgress ({ loaded, total }), signal, mode, onSession (the multipart resume handle) and resume (a stored handle to continue). Use the headers option to send your app's own auth to your handler; it is never sent to storage.

imageUrl({ host, slug, chain?, kind? }) builds https://{host}/{chain}/f/{slug}; kind is derived (with a chain), originals (default without one) or raw.

Responsive images

srcSetPresets builds srcSet from your project's resolution presets as preset:{name} chains; pair it with sizes. presetSrcSet(host, slug, presets) returns the same string for your own markup.

srcSetPresets

<Img2siteImage
  host="my-project.i2scd.net"
  slug="hero"
  alt="Hero"
  srcSetPresets={[{ preset: "small", width: 480 }, { preset: "large", width: 1600 }]}
  sizes="(max-width: 600px) 480px, 1600px"
/>
// src = https://my-project.i2scd.net/originals/f/hero
// srcSet = …/preset:small/f/hero 480w, …/preset:large/f/hero 1600w

When something fails

Errors returned by the upload handler
StatusCodeMeaning
400unknown_routeThe route name is not in the router (or the router has several routes and none was given).
400unsupported_content_typeThe route has no constraint for this type; details.accepted lists the configured keys.
413file_too_largeLarger than the route's maxSize; details.max_bytes.
400invalid_inputThe route's .input(schema) rejected the client input.
401, 403…your codeUploadRejectedError thrown from middleware (default 403 forbidden).
500middleware_failedMiddleware threw anything else; the handler's onError option receives it.
401invalid_tokenThe complete, parts or abort call carried a forged or malformed upload token.
410upload_expiredThe upload token (and its session) expired: start a new upload. Also the API's answer for an expired session.
422the session's error_codeProcessing failed or the session expired (for example unsupported_media_type or image_too_large).
500on_upload_complete_failedThe file is ready but onUploadComplete threw.
502api codeThe img2site API was unreachable or answered 5xx. Refusals such as quota or suspension keep their own status.

In the browser these arrive as Img2siteUploadError with stage (prepare, put, complete), status, code and details. The client adds network_error, aborted, detached, etag_missing, file_mismatch (a resumed upload got a file of another size) and invalid_response. Calls from your server through createClient throw Img2siteError (status, code, details, requestId).

Server-side REST client

The same img2site client covers uploads, files (list, get, delete), signedUrl(), recipes, presets and usage.get(). GET requests are retried with backoff; writes are not.