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#
npm install @videxo/sdkCopy failed. The text is selected; copy it with your keyboard.
@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:
/** 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 }));
}Copy failed. The text is selected; copy it with your keyboard.
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:
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,
});
}
}Copy failed. The text is selected; copy it with your keyboard.
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:
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);
}
}Copy failed. The text is selected; copy it with your keyboard.
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:
/**
* 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);
}Copy failed. The text is selected; copy it with your keyboard.
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 });
}Copy failed. The text is selected; copy it with your keyboard.
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:
/** 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));
}Copy failed. The text is selected; copy it with your keyboard.
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);
}
}Copy failed. The text is selected; copy it with your keyboard.
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:
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);
}
}Copy failed. The text is selected; copy it with your keyboard.
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.
/** 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));
}Copy failed. The text is selected; copy it with your keyboard.
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);
}
}Copy failed. The text is selected; copy it with your keyboard.
A job's outputs[].download_url is also a signed link, valid until download_url_expires_at.
Run it#
cp .env.example .env.local # fill in VIDEXO_API_KEY and VIDEXO_WEBHOOK_SECRET
npm install
npm run dev # http://localhost:3000Copy failed. The text is selected; copy it with your keyboard.
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#
- Operations and Presets: what else a JSON job can make.
- Webhooks: events, retries and the deliveries log.
- TypeScript SDK: every call of
@videxo/sdk, its errors and its retries.