Add read-aloud to a documentation site with the Speechify API

Add a listen button to documentation with the Speechify API. A framework-agnostic walkthrough with a concrete example you can copy.

Updated 4 min read

A listen button on documentation means users can hear a page while they scan it, commute, or follow a walkthrough. This tutorial adds one to a docs site with the Speechify API. It is framework-agnostic, with a complete example you can copy: a Node server with no dependencies and a few lines of browser code.

The pattern is simple: the browser grabs the page text and posts it to a small backend endpoint; that endpoint calls Speechify with your key and hands the audio back. Your API key stays on the server and never reaches the browser. Choose batch for a short clip you want to keep as a file, streaming for a whole page.

Which endpoint do I need?

Two options, depending on how much text you send and what you do with the audio.

  • POST /v1/audio/speech returns one JSON response with the complete audio, base64-encoded, for up to 2,000 characters of spoken text (SSML tags don’t count). Good for a short clip you cache as a file.
  • POST /v1/audio/stream returns the audio bytes in chunks as they are generated, for up to 20,000 characters.

For a docs read-aloud button, streaming is the one to use. Most docs pages run past 2,000 characters, and your endpoint can pass the MP3 bytes straight through to the browser.

How do I send the page text?

Pull the article text from the page, strip navigation and boilerplate so you only send content, and post it to your own endpoint. The synthesis call happens on the server, so the API key is never shipped to the browser.

Browser: the Listen button posts the page text to your endpoint and starts playing as soon as the first audio arrives. MediaSource feeds each chunk to the player as it lands. Where MediaSource can’t take MP3, such as Safari on iPhone, the clip plays once it has fully downloaded:

async function readAloud(text) {
  const res = await fetch("/api/speak", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text }),
  });
  if (!res.ok) {
    const { error } = await res.json().catch(() => ({}));
    throw new Error(error?.message ?? `Read-aloud failed with ${res.status}`);
  }

  const audio = new Audio();
  if (window.MediaSource?.isTypeSupported("audio/mpeg")) {
    const media = new MediaSource();
    audio.src = URL.createObjectURL(media);
    media.addEventListener("sourceopen", async () => {
      const buffer = media.addSourceBuffer("audio/mpeg");
      await res.body.pipeTo(new WritableStream({
        write: (chunk) => new Promise((appended) => {
          buffer.addEventListener("updateend", appended, { once: true });
          buffer.appendBuffer(chunk);
        }),
      }));
      media.endOfStream();
    }, { once: true });
  } else {
    audio.src = URL.createObjectURL(await res.blob());
  }
  await audio.play();
}

Server: the endpoint holds the key, calls the REST API over plain HTTPS, and passes the audio bytes back as they arrive:

import { createServer } from "node:http";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";

// POST /api/speak  { "text": "..." }  ->  MP3 audio, streamed
createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/api/speak") {
    res.writeHead(404).end();
    return;
  }
  try {
    let body = "";
    for await (const chunk of req) body += chunk;
    const { text } = JSON.parse(body);

    const upstream = await fetch("https://api.speechify.ai/v1/audio/stream", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.SPEECHIFY_API_KEY}`,
        "Content-Type": "application/json",
        Accept: "audio/mpeg",
      },
      body: JSON.stringify({ input: text, voice_id: "geffen_32", model: "simba-3.2" }),
    });
    if (!upstream.ok) {
      res.writeHead(upstream.status, { "Content-Type": "application/json" }).end(await upstream.text());
      return;
    }
    res.writeHead(200, { "Content-Type": "audio/mpeg" });
    await pipeline(Readable.fromWeb(upstream.body), res);
  } catch (err) {
    if (res.headersSent) return res.destroy(err);
    const [status, message] = err instanceof SyntaxError
      ? [400, 'Send JSON like {"text": "..."}']
      : [502, "Could not reach the Speechify API"];
    res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify({ error: { message } }));
  }
}).listen(3000);

Save it as server.mjs and run it with Node 18 or newer:

SPEECHIFY_API_KEY=your_key node server.mjs

That’s the whole relay: the button posts to your endpoint, your endpoint calls Speechify, and the audio comes back while it is still being generated. An error from the API, such as a missing key or empty text, reaches the browser with its status and JSON body unchanged. A malformed request gets a 400 and an unreachable API a 502, in the same shape, so readAloud throws one error you can show.

How do I keep the page scannable?

Keep the button small and top-of-page. Playback is an add-on, not the focus. Cache the audio for static pages so repeat visits do not call the API again.

How do I handle long pages?

One streaming request takes up to 20,000 characters, which covers most docs pages. Past that, split the text at paragraph boundaries, synthesize each part, and play them in order. Either way, send clean text and pick a presentable voice.

FAQ

Can I read any page aloud?

Yes, with the page’s text. Send the content you want spoken in the input.

Batch or streaming for read-aloud?

Streaming, for most pages: it takes up to 20,000 characters where batch takes 2,000 spoken characters, and your endpoint passes the bytes straight through. Batch suits a short clip you want to keep as a file.

Which model should I use?

simba-3.2 for English, simba-3.0 for multilingual docs.

This post is narrated by Harper on Simba 3.2 through our text to speech API. How the player works: add read-aloud to your docs and turn speech marks into captions.

Privacy preferences

Choose what we may store on this device. You can change this at any time from the footer.

Strictly necessary

Sign-in, security, load balancing, and remembering your privacy choices. These cannot be switched off.

Always on

Analytics

How the site is used in aggregate - which pages get read, where people get stuck - so we can improve it.

Marketing

Measures which campaigns bring people here, and lets us show relevant ads on other platforms.