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

bash
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.

lib/stt.ts
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.

app/api/transcribe/route.ts
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.

app/actions.ts
"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 });
}

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.

app/api/webhooks/stt/route.ts
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
}

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.