Using Speech Revolutions with Next.js
Call Speech Revolutions from the server side of your Next.js app — a Route Handler or a Server Action — so your API key never ships to the browser. This guide wires up a file upload, live progress you can poll from the client, and a webhook Route Handler for completion callbacks.
Keep the key on the server
The speechrevolutions client reads SPEECHREVOLUTIONS_API_KEY. Only reference it 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, hand the bytes straight to transcribe(), and return the transcript. The file never touches the client's view of your key.
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
Prefer a form that posts directly to a Server Action? Same rule — the function body runs only on the server, so the client stays server-side.
"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
The synchronous transcribe() above blocks the whole request, so the browser only sees the final result. To drive a real progress bar, start the work in the background, store the latest onProgress / onUploadProgress percentage per job, and expose a small progress endpoint the client polls. Live progress for web apps covers the weighting logic in depth; here is the Next.js wiring.
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 });
}Why an in-memory map is a starting point
A Map only works when one process handles both the start and the poll requests. On serverless or multi-instance deployments, back the store with Redis, your database, or a durable KV so any instance can answer the poll.
Webhook Route Handler
For long jobs, skip polling entirely: pass a callbackUrl when you submit and let Speech Revolutions POST you when the job finishes. The platform signs the raw body with HMAC-SHA256 in the X-SR-Signature: sha256=<hex> header. Read the raw bytes — not a re-serialized object — and compare in constant time.
import { NextRequest, NextResponse } from "next/server";
import { createHmac, timingSafeEqual } from "node:crypto";
export const runtime = "nodejs";
const SECRET = process.env.STT_WEBHOOK_SECRET!; // your 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
}Under the hood
transcribe() runs the full upload flow and waits on the SSE job stream, computing percent for the callbacks. See the JavaScript SDK for the full option and result surface.