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.

Keep the key on the server

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

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, pass the bytes to transcribe(), and return the transcript. The API key stays on the server.

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

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.

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

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.

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