Skip to content

DocsIntegrations

React with Next.js

A Next.js app whose route handlers create jobs and verify webhooks, and whose client component uploads straight to Videxo.

What you build#

A Next.js app (App Router) that turns an uploaded file into an MP4, or into an MP4, a poster and a GIF preview in one job. A client component sends the file straight to Videxo with a one-time URL, so it never passes through your app. Route handlers keep the API key, create the job, verify the webhook and send each download to a fresh signed link. Every call to Videxo sits in one file, lib/videxo.ts, with the TypeScript SDK.

The whole project: videxo-nextjs.zip, or browse it in the examples repository (opens in a new tab).

Before you start#

  • An API key from the dashboard's API keys page, in VIDEXO_API_KEY.
  • The webhook signing secret from the dashboard's Webhooks page, in VIDEXO_WEBHOOK_SECRET.

Put both in .env.local. Next.js exposes only variables that start with NEXT_PUBLIC_ to the browser, so these stay on the server; never give them that prefix.

Install#

bash
npm install @videxo/sdk

@videxo/sdk sends the requests, retries a request when a retry is safe and verifies the webhook's signature. The rest is next, react and react-dom.

Create a job#

The JSON bodies sit in requests/, next to the code: one MP4 through POST /v1/transcode, and an MP4, a poster and a GIF preview in one job through POST /v1/jobs. The functions put the uploaded file in as the input and send an Idempotency-Key:

examples/nextjs/lib/videxo.ts · SDK
/** One MP4 from one input: `POST /v1/transcode` with the body in requests/transcode.json. */
export function transcode(input: string, idempotencyKey: string): Promise<Job> {
  const body = { ...transcodeBody, input, ...webhook() } as TranscodeRequest;
  return call((videxo) => videxo.transcode(body, { idempotencyKey }));
}

/** Several outputs from one input in one job: `POST /v1/jobs` with the body in requests/jobs.json. */
export function createJob(input: string, idempotencyKey: string): Promise<Job> {
  const body = { ...jobsBody, inputs: { talk: input }, ...webhook() } as JobRequest;
  return call((videxo) => videxo.jobs.create(body, { idempotencyKey }));
}

Every function goes through one call helper around the SDK's client. An error answer is problem details: the SDK throws a VidexoError, and call passes the problem details on as a VidexoProblem with the code, the detail and the field errors:

examples/nextjs/lib/videxo.ts · SDK
let client: Videxo | undefined;

/** The SDK's client, made on the first call: it retries a 429, a 5xx it may and a network error. */
function videxo(): Videxo {
  const apiKey = process.env.VIDEXO_API_KEY;
  if (!apiKey) throw new Error('Set VIDEXO_API_KEY in the server environment.');
  client ??= new Videxo({ apiKey, ...(process.env.VIDEXO_API_URL ? { baseUrl: process.env.VIDEXO_API_URL } : {}) });
  return client;
}

/** The call's answer; a `VidexoError` becomes a `VidexoProblem` with the API's problem details as they came. */
async function call<T>(send: (videxo: Videxo) => Promise<T>): Promise<T> {
  try {
    return await send(videxo());
  } catch (error) {
    if (!(error instanceof VidexoError)) throw error;
    const body = error.body as Problem | undefined;
    if (typeof body === 'object' && body !== null && typeof body.code === 'string') throw new VidexoProblem(body);
    // No problem details: the network, a timeout or a proxy answered, not the API.
    throw new VidexoProblem({
      type: 'about:blank',
      title: error.title,
      status: error.status ?? 502,
      detail: error.detail,
      code: error.code,
    });
  }
}

The route handler takes the upload's input_url from the page and derives the idempotency key from the upload, so a retried request returns the same job. A VidexoProblem goes back to the page as problem details:

examples/nextjs/app/api/jobs/route.ts · plain HTTP
const UPLOAD = /^videxo:\/\/uploads\/(up_[a-f0-9]{24})$/;

export async function POST(request: Request) {
  try {
    const { kind, input_url } = await request.json();
    // Accept only uploads, so nobody can make this server fetch URLs of their choice.
    const upload = typeof input_url === 'string' ? UPLOAD.exec(input_url)?.[1] : undefined;
    if (!upload) throw badRequest('input_url must be the input_url of an upload.');
    // The same key for the same upload and kind: a retried request answers the same job, never a second one.
    const job = kind === 'jobs' ? await createJob(input_url, `jobs-${upload}`) : await transcode(input_url, `transcode-${upload}`);
    return Response.json(job, { status: 202 });
  } catch (error) {
    return problemResponse(error);
  }
}

Receive the webhook#

When the job ends, Videxo sends a signed POST (Webhooks). The route handler reads the raw body exactly as it arrived, refusing one over 2 MB with 413 (lib/limited-body.ts checks Content-Length and stops reading past the limit), and checks it:

examples/nextjs/lib/videxo.ts · SDK
/**
 * The event when the request is a webhook Videxo signed, in the last five minutes; it throws
 * otherwise. `body` is the raw body exactly as received: parsed and serialized again, it no
 * longer matches the signature.
 */
export function verifyWebhook(body: string, headers: Headers): Promise<WebhookEvent> {
  const secret = process.env.VIDEXO_WEBHOOK_SECRET;
  if (!secret) throw new Error('Set VIDEXO_WEBHOOK_SECRET in the server environment.');
  return verifySignature(body, headers, secret);
}
examples/nextjs/app/api/webhooks/videxo/route.ts · plain HTTP
export async function POST(request: Request) {
  // The raw body, exactly as received: parsed and serialized again, it no longer matches the signature.
  const body = await readLimited(request, MAX_WEBHOOK_BYTES);
  if (body === null) return new Response(null, { status: 413 });
  let event: WebhookEvent;
  try {
    event = await verifyWebhook(body, request.headers);
  } catch {
    return new Response(null, { status: 400 });
  }
  const id = request.headers.get('webhook-id')!;
  if (!handled.has(id)) {
    handled.add(id);
    onJobEnded(event);
  }
  // Answer 2xx within seconds; do slow work in a queue of your own.
  return new Response(null, { status: 204 });
}

verifyWebhook rejects a wrong signature and a timestamp more than five minutes away. A delivery can arrive more than once with the same webhook-id; keep the ids you have handled in a unique column and skip one you have seen.

Upload a file#

A route handler creates the upload with the API key and gives the browser only its one-time URL:

examples/nextjs/lib/videxo.ts · SDK
/** A one-time upload URL for a file the browser sends straight to Videxo; the API key stays here. */
export function createUpload(file: { filename: string; size_bytes: number; content_type: string }): Promise<Upload> {
  return call((videxo) => videxo.uploads.create(file));
}
examples/nextjs/app/api/uploads/route.ts · plain HTTP
export async function POST(request: Request) {
  try {
    const { filename, size_bytes, content_type } = await request.json();
    const upload = await createUpload({ filename, size_bytes, content_type });
    // Only the one-time URL goes to the browser; it holds no API key.
    return Response.json({ input_url: upload.input_url, upload_url: upload.upload_url }, { status: 201 });
  } catch (error) {
    return problemResponse(error);
  }
}

The client component sends the file with PUT to upload_url (no API key; the URL takes one PUT), asks for the job with the upload's input_url and reads the job until it ends:

examples/nextjs/app/upload-form.tsx · plain HTTP
async function submit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();
  setProblem(null);
  setJob(null);
  const form = new FormData(event.currentTarget);
  const file = form.get('file') as File;
  const contentType = file.type || 'application/octet-stream';
  try {
    setStatus('Creating the upload');
    const upload = await call<{ input_url: string; upload_url: string }>('/api/uploads', {
      filename: plainName(file.name),
      size_bytes: file.size,
      content_type: contentType,
    });
    setStatus('Uploading');
    // Straight to Videxo with the one-time URL: no API key, and the file never passes through this app.
    const put = await fetch(upload.upload_url, { method: 'PUT', headers: { 'content-type': contentType }, body: file });
    if (!put.ok) throw await put.json().catch(() => ({ title: 'The upload failed.', detail: `HTTP ${put.status}` }));
    let current = await call<Job>('/api/jobs', { kind: form.get('kind'), input_url: upload.input_url });
    while (current.state === 'queued' || current.state === 'running') {
      setStatus(`Job ${current.id}: ${current.state}`);
      await new Promise((resolve) => setTimeout(resolve, 2000));
      current = await call<Job>(`/api/jobs/${current.id}`);
    }
    setStatus(`Job ${current.id}: ${current.state}`);
    setJob(current);
  } catch (error) {
    setStatus('');
    setProblem(error as Problem);
  }
}

See Files for the size limit and how long an upload lasts.

Download the outputs#

Signed links expire within an hour, so the download route asks for a fresh one each time: outputs.url() reads it from the redirect of GET /v1/jobs/{id}/outputs/{alias}, and the route sends the browser on.

examples/nextjs/lib/videxo.ts · SDK
/** A fresh signed link to one output, from the redirect of `GET /v1/jobs/{id}/outputs/{alias}`. */
export function outputUrl(jobId: string, alias: string): Promise<string> {
  return call((videxo) => videxo.outputs.url(jobId, alias));
}
examples/nextjs/app/api/jobs/[id]/outputs/[alias]/route.ts · plain HTTP
export async function GET(_request: Request, { params }: { params: Promise<{ id: string; alias: string }> }) {
  try {
    const { id, alias } = await params;
    // A fresh link each time: signed links expire within an hour.
    return Response.redirect(await outputUrl(id, alias), 302);
  } catch (error) {
    return problemResponse(error);
  }
}

A job's outputs[].download_url is also a signed link, valid until download_url_expires_at.

Run it#

bash
cp .env.example .env.local    # fill in VIDEXO_API_KEY and VIDEXO_WEBHOOK_SECRET
npm install
npm run dev                   # http://localhost:3000

Open the page, pick a file and choose the job. The page follows the job by reading it every 2 seconds, then lists the outputs as download links.

Webhooks need a public https URL. In development run a tunnel (for example cloudflared tunnel --url http://localhost:3000) and set VIDEXO_WEBHOOK_URL to its address plus /api/webhooks/videxo; each job then carries it as its webhook_url. Or set the address once as your account's endpoint on the Webhooks page.

Next steps#