Speak text in a Next.js app with a route handler

A server route handler that calls the speech API with your key, plus a client button that plays the returned link.

The key lives in a server environment variable. The browser only ever calls your own route.

1. Route handler

app/api/speak/route.ts

export async function POST(req: Request) {
  const { text } = await req.json();
  const clean = String(text ?? "").slice(0, 500);
  if (!clean) return Response.json({ error: "text_required" }, { status: 400 });

  const r = await fetch("https://speakvora.com/api/v1/speak", {
    method: "POST",
    headers: { "x-api-key": process.env.SPEAKVORA_KEY!, "content-type": "application/json" },
    body: JSON.stringify({ text: clean, voice: "af_heart", lang: "en" }),
  });
  const j = await r.json();
  return Response.json(r.ok ? { url: j.url } : { error: j.error }, { status: r.status });
}

2. Environment

.env.local (never commit)

SPEAKVORA_KEY=your_key_here

3. Client button

app/Say.tsx

"use client";
export function Say({ text }: { text: string }) {
  async function play() {
    const r = await fetch("/api/speak", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });
    const j = await r.json();
    if (r.ok) new Audio(j.url).play();
    else console.error(j.error);
  }
  return <button onClick={play}>Speak</button>;
}

Before production

  • Rate-limit your own route: it spends your quota. See errors and retries.
  • Cache the returned link per sentence (guide).
  • Keep the key out of client bundles: only variables prefixed NEXT_PUBLIC_ reach the browser, so do not use that prefix.