Next.js

Run TransformKit from the App Router, Server Actions and Route Handlers. The API key stays on the server; the browser only sees finished URLs.

Create a server-only client

Create the client once in a module marked server-only. Key setup matches the Quickstart.

lib/transform-kit.ts
import 'server-only';
import { TransformKit } from '@transform-kit/sdk';

export const tk = new TransformKit({ apiKey: process.env.API_KEY! });
.env.local
API_KEY=tk_your_key_here

Define a pipeline

Fan-out graphs live on Pipelines: copy responsivePipeline (or your own) into lib/pipelines.ts and import it from Server Actions and Route Handlers.

Transform a file with a Server Action

A form posts a file to a Server Action; runPipeline uploads, submits, and waits.

app/actions.ts
'use server';

import { tk } from '@/lib/transform-kit';
import { responsivePipeline } from '@/lib/pipelines';

export async function transform(formData: FormData) {
  const file = formData.get('file') as File;

  const [result] = await tk.runPipeline(
    [{ bytes: file, contentType: file.type, filename: file.name }],
    responsivePipeline,
  );

  if (!result.ok) throw new Error(result.error);

  return result.outputs.map((o) => ({ label: o.output, url: o.media.url }));
}

Call it from a Client Component and render the results:

app/upload-form.tsx
'use client';

import { useState, useTransition } from 'react';
import { transform } from './actions';

type Output = { label?: string; url: string };

export function UploadForm() {
  const [outputs, setOutputs] = useState<Output[]>([]);
  const [pending, startTransition] = useTransition();

  function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    startTransition(async () => setOutputs(await transform(data)));
  }

  return (
    <form onSubmit={onSubmit}>
      <input type="file" name="file" accept="image/*" required />
      <button disabled={pending}>{pending ? 'Transforming…' : 'Transform'}</button>

      {outputs.map((o) => (
        <figure key={o.url}>
          <img src={o.url} alt={o.label ?? 'output'} />
          <figcaption>{o.label}</figcaption>
        </figure>
      ))}
    </form>
  );
}
The file travels throughyour server, so you hit the deploy target's body limit (Vercel's default is 4.5 MB). For larger files, use presigned browser uploads, see below.

Upload large files directly

Mint an upload ticket in a Server Action, let the browser PUT bytes straight to storage, then submit with input_key. The raw flow is in the HTTP & cURL guide; a working three-path demo (FormData vs managed presign vs BYOS) lives in the nextjs-upload-example repo.

Stream batch progress

For many files, a Route Handler can stream runPipeline's onProgress as newline-delimited JSON. Lazy loaders and concurrency are covered under Pipelines.

app/api/batch/route.ts
import { tk } from '@/lib/transform-kit';
import { responsivePipeline } from '@/lib/pipelines';

export const maxDuration = 300;

export async function POST(req: Request) {
  const form = await req.formData();
  const files = form.getAll('files') as File[];

  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      const send = (o: unknown) => controller.enqueue(encoder.encode(JSON.stringify(o) + '\n'));

      const results = await tk.runPipeline(
        files.map((f) => ({ bytes: f, contentType: f.type, filename: f.name })),
        responsivePipeline,
        { concurrency: 4, onProgress: (e) => send({ type: 'progress', ...e }) },
      );

      send({ type: 'done', results });
      controller.close();
    },
  });

  return new Response(stream, { headers: { 'Content-Type': 'application/x-ndjson' } });
}

Render results

Result URLs are signed and external, a plain <img> works. For next/image, allowlist the storage host under images.remotePatterns. Retention and copying bytes to your CDN are covered in the HTTP & cURL guide.