Speech generation in the Vercel AI SDK with Speechify

12 min read

The AI SDK ships generateSpeech() but no Speechify provider. So we wrote one: a dependency-free custom speech model that maps POST /v1/audio/speech onto the SDK's SpeechModelV4 interface, key held server-side in a Next.js route.

Developer Relations · SpeechifyAI Labs

The Vercel AI SDK has a generateSpeech() function, but no Speechify provider ships with it. You don’t need one. A speech model in the AI SDK is just an object with a doGenerate() method, so you can write a custom model that maps generateSpeech() onto Speechify’s POST /v1/audio/speech in about a hundred lines, with no extra dependency. This post builds that model and wires it into a Next.js page.

I built this against ai@7.0.34 and @ai-sdk/provider@4.0.3 on Next.js 16. The whole thing is in demos/ai-sdk-speechify-speech, running against the live API. If you want a key to follow along, grab one here .

Why build a provider instead of calling the API directly

Fair question. You can call POST /v1/audio/speech with fetch and skip all of this. If your app already speaks HTTP to Speechify, keep doing that.

The reason to wrap it is that you’re already inside the AI SDK. You’re calling generateText and generateObject, your code passes models around as values, and you want speech to look the same as everything else: generateSpeech({ model, text }), one shape, swap the model to change vendor. A custom provider buys you that uniformity without waiting for an official package. It’s the same argument as the speech-sdk provider post: meet the code where it already is.

What a speech model has to return

generateSpeech() in AI SDK core takes a model, some text, and options like voice and outputFormat. The model does the work in one doGenerate() call. The interface is SpeechModelV4 from @ai-sdk/provider, and the parts that matter are the identifiers and that one method:

import type {
  SpeechModelV4,
  SpeechModelV4CallOptions,
  SpeechModelV4Result,
} from "@ai-sdk/provider";

doGenerate() receives the call options (text, voice, format, and a few settings), and returns the audio plus some metadata. That’s the whole contract. Everything below is mapping Speechify’s request and response onto it.

AI SDK + Speechify

One SpeechModel, mapped to one endpoint

Your code calls the AI SDK's generateSpeech(). The custom model's doGenerate() turns that into an HTTP call. Speechify returns base64 audio. The key stays in the route handler the whole way.

  1. Next.js route handler Calls generateSpeech() Reads SPEECHIFY_API_KEY from the server environment and asks the SDK for speech. generateSpeech({ model, text, voice })
  2. Custom speech model doGenerate() Maps voice to voice_id, sets model: simba-3.2, and posts with plain fetch. POST /v1/audio/speech
  3. Speechify API Synthesizes Returns audio_data (base64), billable_characters_count, and speech_marks. { audio_data, ... }

The audio comes back to the browser as a data URL. The key does not. The provider file is imported only by the route handler, so it never lands in client JavaScript.

The custom speech model

Here is the core of the provider. It’s plain fetch, no SDK, so it drops into any runtime that has fetch:

// app/lib/speechify-provider.ts
import type {
  SharedV4Warning,
  SpeechModelV4,
  SpeechModelV4CallOptions,
  SpeechModelV4Result,
} from "@ai-sdk/provider";

const DEFAULT_BASE_URL = "https://api.speechify.ai";

export function createSpeechify(settings: { apiKey?: string; baseURL?: string } = {}) {
  const baseURL = settings.baseURL ?? DEFAULT_BASE_URL;

  function speech(modelId: string): SpeechModelV4 {
    return {
      specificationVersion: "v4",
      provider: "speechify",
      modelId,

      async doGenerate(
        options: SpeechModelV4CallOptions,
      ): Promise<SpeechModelV4Result> {
        const apiKey = settings.apiKey ?? process.env.SPEECHIFY_API_KEY;
        if (!apiKey) throw new Error("Missing Speechify API key.");

        const { text, voice, outputFormat } = options;
        const warnings: SharedV4Warning[] = []; // populated in the next section

        const body = {
          input: text,
          voice_id: voice ?? "harper_32",
          audio_format: outputFormat ?? "mp3",
          model: modelId,
        };

        const res = await fetch(`${baseURL}/v1/audio/speech`, {
          method: "POST",
          headers: {
            Authorization: `Bearer ${apiKey}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify(body),
          signal: options.abortSignal,
        });

        if (!res.ok) {
          throw new Error(
            `Speechify POST /v1/audio/speech responded ${res.status}: ${await res.text()}`,
          );
        }

        const data = await res.json();

        return {
          // The endpoint returns base64; the spec says to pass audio
          // through in whatever encoding the API used.
          audio: data.audio_data,
          warnings,
          request: { body },
          response: { timestamp: new Date(), modelId },
          providerMetadata: {
            speechify: {
              audioFormat: data.audio_format,
              billableCharactersCount: data.billable_characters_count ?? null,
              speechMarks: data.speech_marks ?? null,
            },
          },
        };
      },
    };
  }

  return { speech };
}

export const speechify = createSpeechify();

Two mapping decisions worth calling out. Speechify’s voice_id is the AI SDK’s voice, so that’s a straight rename. And POST /v1/audio/speech returns base64 audio inside a JSON envelope, not raw bytes, so doGenerate() passes data.audio_data straight through. The AI SDK spec says to return the audio in whatever encoding the API produced, and the SDK exposes both .base64 and .uint8Array on the result, so the consumer picks.

The interesting fields ride along in providerMetadata. POST /v1/audio/speech returns more than audio: a billable_characters_count and a full speech_marks object. There’s nowhere for those in the generic generateSpeech() result, so they go under providerMetadata.speechify where anyone who wants them can read them.

Handling the settings the API doesn’t have

generateSpeech() can pass speed, instructions, and language. Speechify’s /v1/audio/speech doesn’t take those as top-level fields, so the honest thing is to warn rather than silently drop them. Push into the warnings array from the snippet above for each one that’s set:

if (options.speed != null) {
  warnings.push({
    type: "unsupported",
    feature: "speed",
    details: "Use an SSML prosody rate in the input text instead.",
  });
}
if (options.instructions) {
  warnings.push({
    type: "unsupported",
    feature: "instructions",
    details: "Speechify controls delivery with SSML in the input text, not a free-text instruction field.",
  });
}
if (options.language) {
  warnings.push({
    type: "unsupported",
    feature: "language",
    details: "Language follows the model: use simba-multilingual for non-English input.",
  });
}

Because the model already returns that array, these surface on result.warnings. The caller sees precisely which of its options had no effect, instead of wondering why speed: 1.5 did nothing.

Wire it into a Next.js route

The key stays server-side. The provider file is imported only by a route handler, so it’s never bundled into client JavaScript, and SPEECHIFY_API_KEY never reaches the browser. Without a gate, that route is a free proxy onto a paid API: anyone who finds the URL can burn through billable characters on your key, and a solved Turnstile challenge only proves a human is calling once, not that they won’t call again immediately. The demo checks a Cloudflare Turnstile token, bounds requests per IP, and rejects text over Speechify’s own 2000-character input limit, all before calling generateSpeech():

// app/api/speech/route.ts
import { NextResponse } from "next/server";
import { generateSpeech } from "ai";
import { speechify } from "../../lib/speechify-provider";
import { verifyTurnstile } from "../../lib/turnstile";
import { rateLimit } from "../../lib/rate-limit";

export const runtime = "nodejs";

// Matches the server-side cap on GetSpeechRequest.input.
const MAX_TEXT_LENGTH = 2000;

// Turnstile proves a human solved a challenge once; it doesn't cap how many
// paid requests that same caller sends afterward.
const RATE_LIMIT = { max: 5, windowMs: 60_000 };

export async function POST(req: Request) {
  if (!(await verifyTurnstile(req))) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  if (!rateLimit(req, RATE_LIMIT)) {
    return NextResponse.json(
      { error: "Too many requests. Wait a minute and try again." },
      { status: 429 },
    );
  }

  const { text, voiceId } = await req.json();

  if (typeof text !== "string" || typeof voiceId !== "string") {
    return NextResponse.json(
      { error: "text and voiceId are required" },
      { status: 400 },
    );
  }

  if (text.length > MAX_TEXT_LENGTH) {
    return NextResponse.json(
      { error: `text must be ${MAX_TEXT_LENGTH} characters or fewer` },
      { status: 400 },
    );
  }

  try {
    const result = await generateSpeech({
      model: speechify.speech("simba-3.2"),
      text,
      voice: voiceId,
    });

    return NextResponse.json({
      audio: result.audio.base64,
      mediaType: result.audio.mediaType,
      warnings: result.warnings,
      providerMetadata: result.providerMetadata,
    });
  } catch (err) {
    // doGenerate() throws a plain Error on any non-2xx from Speechify (bad
    // voice/model, rate limit, upstream 5xx). Uncaught, that crashes the
    // route with a bodyless 500 and the caller never learns why.
    const message = err instanceof Error ? err.message : "Speech generation failed.";
    return NextResponse.json({ error: message }, { status: 502 });
  }
}

That’s the payoff for the wrapping work. generateSpeech({ model: speechify.speech("simba-3.2"), text, voice }) reads like every other AI SDK call. The browser solves a Turnstile challenge, posts text plus the token to /api/speech, and gets audio back. verifyTurnstile() (in the demo’s lib/turnstile.ts) posts the token to Cloudflare’s siteverify endpoint and only lets the request through on a real pass. rateLimit() (in lib/rate-limit.ts) tracks a small in-memory window per IP, five requests a minute, so passing Turnstile once isn’t a license to call the route on repeat. The length check rejects an oversized request locally instead of spending a call on one POST /v1/audio/speech would 400 anyway, and the catch turns an upstream failure into a real error message instead of an opaque 500.

The demo page on first load. A heading reads 'Speech generation with the AI SDK and Speechify', followed by a Pick a voice step with a select showing 'Harper (female)', and a Generate speech step with a text area pre-filled with a sentence and a 'Generate with generateSpeech()' button.
The demo on load. The voice select is populated from GET /v1/voices server-side, filtered to the eight voices simba-3.2 serves today.

Populate the voice picker from GET /v1/voices

simba-3.2 serves from a curated set of voices, so the picker should show those, not the whole 951-voice catalog. A second route fetches the catalog server-side and returns the display names for the model’s voice list. Note the response envelope: since the June 2026 catalog change, GET /v1/voices returns an object with a voices array, next_cursor, and has_more, not a bare array. Read data.voices, not data:

// app/api/voices/route.ts
import { NextResponse } from "next/server";

export const runtime = "nodejs";

const SIMBA_32_VOICES = [
  "beatrice_32", "dominic_32", "edmund_32", "geffen_32",
  "harper_32", "hugh_32", "imogen_32", "wyatt_32",
];

function fallback() {
  return NextResponse.json({
    voices: SIMBA_32_VOICES.map((id) => ({ id, display_name: id })),
  });
}

export async function GET() {
  const apiKey = process.env.SPEECHIFY_API_KEY;
  if (!apiKey) return fallback();

  try {
    const res = await fetch("https://api.speechify.ai/v1/voices", {
      headers: { Authorization: `Bearer ${apiKey}` },
    });
    if (!res.ok) return fallback();

    const data = await res.json(); // { voices, next_cursor, has_more }
    const catalog = new Map((data.voices ?? []).map((v) => [v.id, v]));

    return NextResponse.json({
      voices: SIMBA_32_VOICES.map((id) => ({
        id,
        display_name: catalog.get(id)?.display_name ?? id,
        gender: catalog.get(id)?.gender ?? null,
      })),
    });
  } catch {
    return fallback();
  }
}

When the demo runs, that call returns the eight voices with real display names and genders: Beatrice, Dominic, Edmund, Geffen, Harper, Hugh, Imogen, Wyatt. If the key is missing, the request fails, or the response isn’t OK, fallback() returns the raw id list so the page still works.

What comes back

Press generate and the page plays the audio, then shows what the SDK returned. Here is a real run against the live API, synthesizing a 98-character sentence:

The demo after generating. The Generate speech step now shows an audio player, a status line reading 'Done. Press play.', and a 'What came back' step listing Audio format mp3, Billable characters 98, and Warnings 0.
A real generateSpeech() result. The player is fed a base64 data URL; the panel below reads the fields off result.providerMetadata.speechify.

The metadata panel reads straight off providerMetadata.speechify. For that run the endpoint reported billable_characters_count: 98 and an mp3 format, with no warnings because the route passes only text and voice. The speech_marks object rides along too, keyed by character offset and time (times are in milliseconds), with a chunks array carrying one entry per word:

{
  "audioFormat": "mp3",
  "billableCharactersCount": 98,
  "speechMarks": {
    "type": "sentence",
    "start": 0,
    "end": 98,
    "start_time": 0,
    "end_time": 6579,
    "value": "The AI SDK calls this provider, the provider calls Speechify, and the key never leaves the server.",
    "chunks": [
      { "type": "word", "start": 0, "end": 3, "start_time": 0, "end_time": 171, "value": "The" }
    ]
  }
}

If you want to turn those marks into synced captions, that’s the speech marks captions post.

Where this breaks, and what to reach for instead

generateSpeech() is request/response. It waits for the whole clip, then returns it. For anything a user waits on, like a conversational turn, you want the first audio bytes as early as possible, and that’s POST /v1/audio/stream, not this. The Web Audio streaming post covers that path. This custom model is the right tool when you’re generating a clip and playing it back, not when latency to first byte is the number you’re optimizing.

The other edge is model and voice pairing. simba-3.2 only accepts a voice from its allow-list, so if you let a user type an arbitrary voice_id you’ll get an error from the API rather than audio. The demo sidesteps it by populating the picker from the model’s voice list. If you build your own selector, filter it the same way.

If a Speechify provider package for the AI SDK would be useful to you, the demo repo is the place to say so. For now, the one file above is the whole integration, and it stays the same file when a later model lands. You change the string you pass to speech().

FAQ

Does the Vercel AI SDK have an official Speechify provider?

Not as of ai@7.0.34. The AI SDK’s provider registry lists a handful of speech vendors and Speechify isn’t one of them yet. You don’t have to wait for it: a speech model is any object implementing the SpeechModelV4 interface from @ai-sdk/provider, so a custom provider is a single file with one doGenerate() method that calls POST /v1/audio/speech.

How do I return Speechify’s base64 audio from doGenerate()?

POST /v1/audio/speech returns audio as a base64 string in audio_data, inside a JSON body. Return that string directly as the audio field of the SpeechModelV4Result. The AI SDK’s spec says to pass audio through in whatever encoding the API used, and it exposes both result.audio.base64 and result.audio.uint8Array to the caller, so you don’t decode it yourself.

Where do billable_characters_count and speech_marks go?

The generic generateSpeech() result has no field for them, so put them under providerMetadata.speechify. The response from /v1/audio/speech includes billable_characters_count and a speech_marks object alongside the audio, and providerMetadata is the AI SDK’s escape hatch for exactly this kind of vendor-specific data. The caller reads them off result.providerMetadata.speechify.

Why filter the voice list to the *_32 voices?

simba-3.2 serves from a curated allow-list, currently eight voices (beatrice_32 through wyatt_32), not the full catalog. Sending an arbitrary catalog voice_id to simba-3.2 returns an error, not audio. Fetch GET /v1/voices server-side, keep the ids the model supports, and populate your picker from those so a user can’t pick a voice the model won’t accept.

Does the API key stay off the client in this setup?

Yes. generateSpeech() and the provider both run inside app/api/* route handlers, which execute only on the server. The provider file is imported by those handlers, never by a client component, so it isn’t bundled into browser JavaScript, and SPEECHIFY_API_KEY is read from the server environment. The browser only ever talks to your own same-origin routes.

The full runnable demo is in demos/ai-sdk-speechify-speech, and the endpoint reference is in the Speechify docs .