Using Speech Revolutions with Next.js
Call Speech Revolutions from the server side of your Next.js app, in a Route Handler or a Server Action, so your API key never reaches the browser. This guide covers a file upload, live progress that the client polls, and a webhook Route Handler for completion callbacks.
The speechrevolutions client reads SPEECHREVOLUTIONS_API_KEY. Reference it only from server code: Route Handlers, Server Actions, or route.ts files. Never expose it through a NEXT_PUBLIC_* variable or import the client into a "use client" component.
Install
npm install speechrevolutions
# .env.local (server-only — no NEXT_PUBLIC_ prefix)
SPEECHREVOLUTIONS_API_KEY=stt_...A shared server-only client
Create the client once in a module you only import from server code. The import "server-only" guard turns any accidental client-side import into a build error.
import "server-only";
import { SpeechRevolutions } from "speechrevolutions";
// Reads SPEECHREVOLUTIONS_API_KEY from the server environment.
export const stt = new SpeechRevolutions();Upload a user file from a Route Handler
Accept the browser's multipart/form-data in a Route Handler, pass the bytes to transcribe(), and return the transcript. The API key stays on the server.
import { NextRequest, NextResponse } from "next/server";
import { stt } from "@/lib/stt";
export const runtime = "nodejs"; // the SDK needs the Node runtime, not edge
export async function POST(req: NextRequest) {
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "no file" }, { status: 400 });
}
// Pass the raw bytes (a Buffer) to the SDK — blocks until done.
const bytes = Buffer.from(await file.arrayBuffer());
const result = await stt.transcribe(bytes, { speakerLabels: true });
return NextResponse.json({ text: result.text });
}Or a Server Action
A form can also post directly to a Server Action. The function body runs only on the server, so the key never reaches the browser.
"use server";
import { stt } from "@/lib/stt";
export async function transcribeAction(formData: FormData) {
const file = formData.get("file") as File;
const bytes = Buffer.from(await file.arrayBuffer());
const result = await stt.transcribe(bytes, { speakerLabels: true });
return { text: result.text };
}Show live progress to the client
transcribe() above blocks the whole request, so the browser sees only the final result. To show a progress bar, start the work in the background, store the latest onProgress and onUploadProgress percentage per job, and expose a progress endpoint that the client polls. Live progress for web apps explains the weighting; this section shows the Next.js code.
import { NextRequest, NextResponse } from "next/server";
import { stt } from "@/lib/stt";
import { randomUUID } from "node:crypto";
export const runtime = "nodejs";
// In-memory for a single instance. Use Redis / your DB in production so
// every instance and the progress route can read the same value.
type Snapshot = { phase: string; percent: number; text?: string };
const JOBS = new Map<string, Snapshot>();
export function getJob(id: string) {
return JOBS.get(id);
}
export async function POST(req: NextRequest) {
const form = await req.formData();
const file = form.get("file") as File;
const bytes = Buffer.from(await file.arrayBuffer());
const jobId = randomUUID();
JOBS.set(jobId, { phase: "starting", percent: 0 });
// Fire and forget — return the id immediately; the callbacks update the map.
stt
.transcribe(bytes, {
onUploadProgress: (e) =>
JOBS.set(jobId, { phase: "upload", percent: (e.percent ?? 0) * 0.15 }),
onProgress: (e) =>
JOBS.set(jobId, {
phase: "transcribe",
percent: 15 + (e.percent ?? 0) * 0.85,
}),
})
.then((result) =>
JOBS.set(jobId, { phase: "done", percent: 100, text: result.text }),
)
.catch(() => JOBS.set(jobId, { phase: "failed", percent: 0 }));
return NextResponse.json({ jobId });
}A Map works only when one process handles both the start and the poll requests. On serverless or multi-instance deployments, use Redis, your database, or a durable KV store so any instance can answer the poll.
Webhook Route Handler
For long jobs, skip polling: pass a callbackUrl when you submit, and Speech Revolutions POSTs to it when the job finishes. The raw body is signed with HMAC-SHA256 and the signature is sent in the X-SR-Signature: sha256=<hex> header. Verify against the raw bytes, not a re-serialized object, and compare in constant time. Copy your signing secret from the console (API Keys → Webhook signing secret) into SPEECHREVOLUTIONS_WEBHOOK_SECRET. The webhooks guide covers the payload, retries, and secret rotation.
import { NextRequest, NextResponse } from "next/server";
import { createHmac, timingSafeEqual } from "node:crypto";
export const runtime = "nodejs";
const SECRET = process.env.SPEECHREVOLUTIONS_WEBHOOK_SECRET!; // Console → API Keys → Webhook signing secret
function verify(raw: string, header: string | null): boolean {
const expected = "sha256=" + createHmac("sha256", SECRET).update(raw).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
export async function POST(req: NextRequest) {
const raw = await req.text(); // verify against the exact bytes received
if (!verify(raw, req.headers.get("x-sr-signature"))) {
return NextResponse.json({ error: "bad signature" }, { status: 401 });
}
const event = JSON.parse(raw);
// { job_id, status: "completed" | "failed", download_url?, step?, reason? }
if (event.status === "completed") {
// mark the job done; fetch event.download_url or getTranscript(event.job_id)
} else {
// event.step / event.reason describe the failure
}
return NextResponse.json({ ok: true }); // a 2xx acks delivery; 5xx is retried
}transcribe() runs the full upload flow and waits on the SSE job stream, computing percent for the callbacks. See the JavaScript SDK for all options and result fields.