From 7eec1fe294cfd2a6b37cbd7bd92abd12bd110558 Mon Sep 17 00:00:00 2001
From: luke-speechify <289678208+luke-speechify@users.noreply.github.com>
Date: Fri, 21 Aug 2026 00:36:15 +0100
Subject: [PATCH 1/2] feat: add react-tts-component demo
A drop-in <100-line React component (SpeechifyVoice) that speaks any text
via the Speechify API, with the key held server-side in a route handler.
Hosted under /react-tts-component, gated with the shared Turnstile helper.
---
README.md | 1 +
demos/react-tts-component/.env.example | 1 +
demos/react-tts-component/.gitignore | 5 +
demos/react-tts-component/README.md | 62 +++++++++
.../app/api/speak/route.ts | 34 +++++
demos/react-tts-component/app/globals.css | 121 ++++++++++++++++++
demos/react-tts-component/app/layout.tsx | 21 +++
.../react-tts-component/app/lib/turnstile.ts | 37 ++++++
demos/react-tts-component/app/page.tsx | 94 ++++++++++++++
.../components/SpeechifyVoice.tsx | 78 +++++++++++
demos/react-tts-component/demo.json | 6 +
demos/react-tts-component/next.config.ts | 13 ++
demos/react-tts-component/package.json | 26 ++++
demos/react-tts-component/tsconfig.json | 41 ++++++
pnpm-lock.yaml | 28 ++++
pnpm-workspace.yaml | 1 +
site/public/index.html | 4 +-
vercel.json | 8 ++
18 files changed, 579 insertions(+), 2 deletions(-)
create mode 100644 demos/react-tts-component/.env.example
create mode 100644 demos/react-tts-component/.gitignore
create mode 100644 demos/react-tts-component/README.md
create mode 100644 demos/react-tts-component/app/api/speak/route.ts
create mode 100644 demos/react-tts-component/app/globals.css
create mode 100644 demos/react-tts-component/app/layout.tsx
create mode 100644 demos/react-tts-component/app/lib/turnstile.ts
create mode 100644 demos/react-tts-component/app/page.tsx
create mode 100644 demos/react-tts-component/components/SpeechifyVoice.tsx
create mode 100644 demos/react-tts-component/demo.json
create mode 100644 demos/react-tts-component/next.config.ts
create mode 100644 demos/react-tts-component/package.json
create mode 100644 demos/react-tts-component/tsconfig.json
diff --git a/README.md b/README.md
index b23a0c8..f0d9c7b 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ Demos with a **Live** link run in your browser at [demos.speechify.ai](https://d
| [`demos/mastra-agent-speechify/`](./demos/mastra-agent-speechify) | TypeScript (Mastra) | | Text-in, speech-out Mastra Agent using an OpenAI LLM for replies and Speechify's simba-3.2 model for TTS via `@mastra/voice-speechify`. |
| [`demos/voice-agent-showcase/`](./demos/voice-agent-showcase) | Cloudflare Workers | | One page, ten live Voice Agents API demos: calendar booking, policy-bound support, a page copilot, form intake, US outbound calls with a 5-minute cap, a voice gallery, mid-call language handoff, cross-call memory, a grounded knowledge base, and dual-control troubleshooting. |
| [`demos/vercel-ai-sdk/`](./demos/vercel-ai-sdk) | TypeScript (Vercel AI SDK) | | Speechify TTS through the Vercel AI SDK's unified `generateSpeech` interface via the official `@speechify/vercel` provider — one-line swap from OpenAI/ElevenLabs, plus word-level speech marks from `providerMetadata`. |
+| [`demos/react-tts-component/`](./demos/react-tts-component) | Next.js | [Open](https://demos.speechify.ai/react-tts-component) | A drop-in <100-line React component that speaks any text. Type, hit play, hear it — the API key stays server-side in a route handler. |
## Get an API key
diff --git a/demos/react-tts-component/.env.example b/demos/react-tts-component/.env.example
new file mode 100644
index 0000000..534cec4
--- /dev/null
+++ b/demos/react-tts-component/.env.example
@@ -0,0 +1 @@
+SPEECHIFY_API_KEY=your_api_key_here
diff --git a/demos/react-tts-component/.gitignore b/demos/react-tts-component/.gitignore
new file mode 100644
index 0000000..95d1bcb
--- /dev/null
+++ b/demos/react-tts-component/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.next/
+.env
+next-env.d.ts
+*.tsbuildinfo
diff --git a/demos/react-tts-component/README.md b/demos/react-tts-component/README.md
new file mode 100644
index 0000000..ad8b273
--- /dev/null
+++ b/demos/react-tts-component/README.md
@@ -0,0 +1,62 @@
+# Voice in a React app
+
+A drop-in React component that adds a Speechify voice to any app. Give it text,
+it plays that text as speech. The Speechify API key never reaches the browser —
+synthesis goes through a one-route server proxy.
+
+Pairs with the Speechify post *Adding a voice to a React app with the Speechify
+SDK*. It complements (doesn't repeat)
+[Building an AI Voice Cloning Web App with Next.js and Speechify](https://speechify.ai/blog/building-an-ai-voice-cloning-web-app-with-nextjs-and-speechify)
+— read that one for the full cloning app.
+
+## What you get
+
+- **[`components/SpeechifyVoice.tsx`](./components/SpeechifyVoice.tsx)** — the
+ whole point. Under 100 lines. Props: `text`, optional `voiceId`, `endpoint`,
+ `label`, and a `getToken` hook for abuse-gated deployments. Copy it into your
+ own app.
+- **[`app/api/speak/route.ts`](./app/api/speak/route.ts)** — a Next.js route
+ handler that calls `client.audio.speech(...)` server-side and returns base64
+ MP3, so `SPEECHIFY_API_KEY` stays on the server.
+- A small page (`app/page.tsx`) that wires the component to a textarea and a
+ voice picker.
+
+## Run it yourself
+
+```bash
+cp .env.example .env # paste your Speechify API key
+pnpm install
+pnpm dev # http://localhost:8767/react-tts-component
+```
+
+Get an API key at [platform.speechify.ai/api-keys](https://platform.speechify.ai/api-keys).
+
+## Use the component in your app
+
+```tsx
+import { SpeechifyVoice } from "./components/SpeechifyVoice";
+
+;
+```
+
+The component POSTs `{ text, voiceId }` to `endpoint` (default `/api/speak`),
+expects `{ audio }` (base64 MP3) back, and plays it. Point `endpoint` at your
+own proxy route in any framework — the component doesn't care what's behind it.
+
+## Where the code came from
+
+Built on the [`@speechify/api`](https://www.npmjs.com/package/@speechify/api)
+TTS client — one `client.audio.speech({ input, voice_id, audio_format, model })`
+call. Model `simba-3.2`, MP3 output. Browse voices at
+[platform.speechify.ai](https://platform.speechify.ai).
+
+## Abuse protection (hosted)
+
+The hosted build gates `/api/speak` with Cloudflare Turnstile via the shared
+[`app/lib/turnstile.ts`](./app/lib/turnstile.ts) helper. It fail-opens when
+`TURNSTILE_SECRET_KEY` is unset, so local dev and forks work with zero config.
+
+## Prerequisites
+
+- Node 20+.
+- A Speechify API key (the free tier covers this demo).
diff --git a/demos/react-tts-component/app/api/speak/route.ts b/demos/react-tts-component/app/api/speak/route.ts
new file mode 100644
index 0000000..7148bc4
--- /dev/null
+++ b/demos/react-tts-component/app/api/speak/route.ts
@@ -0,0 +1,34 @@
+import { NextResponse } from "next/server";
+import { SpeechifyClient, SpeechifyError } from "@speechify/api";
+import { verifyTurnstile } from "../../lib/turnstile";
+
+export const runtime = "nodejs";
+
+const client = new SpeechifyClient({ token: process.env.SPEECHIFY_API_KEY });
+
+export async function POST(req: Request) {
+ if (!(await verifyTurnstile(req))) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const { text, voiceId } = await req.json();
+
+ if (typeof text !== "string" || !text.trim()) {
+ return NextResponse.json({ error: "text is required" }, { status: 400 });
+ }
+
+ try {
+ const speech = await client.audio.speech({
+ input: text.slice(0, 2000),
+ voice_id: typeof voiceId === "string" && voiceId ? voiceId : "geffen_32",
+ audio_format: "mp3",
+ model: "simba-3.2",
+ });
+ return NextResponse.json({ audio: speech.audio_data });
+ } catch (err) {
+ if (err instanceof SpeechifyError) {
+ return NextResponse.json({ error: err.message }, { status: err.statusCode ?? 500 });
+ }
+ throw err;
+ }
+}
diff --git a/demos/react-tts-component/app/globals.css b/demos/react-tts-component/app/globals.css
new file mode 100644
index 0000000..3268cf2
--- /dev/null
+++ b/demos/react-tts-component/app/globals.css
@@ -0,0 +1,121 @@
+:root {
+ color-scheme: light dark;
+ --fg: #111;
+ --bg: #fff;
+ --muted: #666;
+ --border: #ddd;
+ --surface: #f6f6f6;
+ --accent: #111;
+ --accent-fg: #fff;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --fg: #f2f2f2;
+ --bg: #0c0c0c;
+ --muted: #999;
+ --border: #2a2a2a;
+ --surface: #161616;
+ --accent: #f2f2f2;
+ --accent-fg: #0c0c0c;
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ padding: 2rem 1rem;
+ background: var(--bg);
+ color: var(--fg);
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ line-height: 1.5;
+}
+
+main {
+ max-width: 34rem;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+h1 {
+ font-size: 1.6rem;
+ font-weight: 500;
+ margin: 0 0 0.5rem;
+}
+
+.lead {
+ color: var(--muted);
+ margin: 0;
+}
+
+code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ background: var(--surface);
+ padding: 0.1em 0.35em;
+ border-radius: 4px;
+ font-size: 0.9em;
+}
+
+.step {
+ border: 1px solid var(--border);
+ border-radius: 0.6rem;
+ padding: 1.1rem;
+ background: var(--surface);
+}
+
+.step h2 {
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--muted);
+ margin: 0 0 0.75rem;
+}
+
+textarea,
+select {
+ width: 100%;
+ font: inherit;
+ color: var(--fg);
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 0.4rem;
+ padding: 0.6rem 0.7rem;
+ resize: vertical;
+}
+
+.play {
+ display: flex;
+ justify-content: center;
+}
+
+.sv-btn {
+ font: inherit;
+ font-weight: 500;
+ cursor: pointer;
+ color: var(--accent-fg);
+ background: var(--accent);
+ border: none;
+ border-radius: 999px;
+ padding: 0.7rem 2rem;
+}
+
+.sv-btn:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
+footer {
+ color: var(--muted);
+ font-size: 0.9rem;
+ border-top: 1px solid var(--border);
+ padding-top: 1rem;
+}
+
+footer a {
+ color: inherit;
+}
diff --git a/demos/react-tts-component/app/layout.tsx b/demos/react-tts-component/app/layout.tsx
new file mode 100644
index 0000000..c099c8e
--- /dev/null
+++ b/demos/react-tts-component/app/layout.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from "next";
+import type { ReactNode } from "react";
+import Script from "next/script";
+import "./globals.css";
+
+export const metadata: Metadata = {
+ title: "Voice in a React app with Speechify",
+ description:
+ "A drop-in React component that speaks any text with the Speechify API, key held server-side.",
+};
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/demos/react-tts-component/app/lib/turnstile.ts b/demos/react-tts-component/app/lib/turnstile.ts
new file mode 100644
index 0000000..6fee926
--- /dev/null
+++ b/demos/react-tts-component/app/lib/turnstile.ts
@@ -0,0 +1,37 @@
+// Verifies a Turnstile token against Cloudflare siteverify. Returns true iff
+// the caller is allowed to proceed.
+//
+// Fail-open contract: when TURNSTILE_SECRET_KEY isn't set (local dev, fork
+// deploys, anywhere the operator hasn't configured Turnstile) OR when the
+// siteverify request itself errors, returns true. The alternative is
+// breaking the demo whenever Turnstile isn't configured — a worse experience
+// than leaving the abuse gate briefly open. Real prod hardening would flip
+// this to fail-closed; this is a reference demo.
+const SITEVERIFY_URL =
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify";
+
+export async function verifyTurnstile(req: Request): Promise {
+ const secret = process.env.TURNSTILE_SECRET_KEY;
+ if (!secret) return true;
+
+ const token = req.headers.get("x-turnstile-token");
+ if (!token) return false;
+
+ const form = new URLSearchParams();
+ form.set("secret", secret);
+ form.set("response", token);
+ const remoteip = req.headers
+ .get("x-forwarded-for")
+ ?.split(",")[0]
+ ?.trim();
+ if (remoteip) form.set("remoteip", remoteip);
+
+ try {
+ const cf = await fetch(SITEVERIFY_URL, { method: "POST", body: form });
+ if (!cf.ok) return true;
+ const result = (await cf.json()) as { success?: boolean };
+ return Boolean(result?.success);
+ } catch {
+ return true;
+ }
+}
diff --git a/demos/react-tts-component/app/page.tsx b/demos/react-tts-component/app/page.tsx
new file mode 100644
index 0000000..242f7ea
--- /dev/null
+++ b/demos/react-tts-component/app/page.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { SpeechifyVoice } from "../components/SpeechifyVoice";
+
+// Curated simba-3.2 voices. Browse the full catalog at platform.speechify.ai.
+const VOICES = ["geffen_32", "harper_32", "dominic_32", "beatrice_32", "wyatt_32"];
+
+type TurnstileHandle = {
+ getToken: (opts?: { timeout?: number }) => Promise;
+ reset: () => void;
+};
+
+declare global {
+ interface Window {
+ SpeechifyTurnstile?: {
+ render: (target: string | HTMLElement, options?: unknown) => Promise;
+ };
+ }
+}
+
+export default function Home() {
+ const [text, setText] = useState("Adding a voice to a React app takes about ten lines.");
+ const [voiceId, setVoiceId] = useState(VOICES[0]);
+ const turnstileRef = useRef(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ while (!window.SpeechifyTurnstile && !cancelled) {
+ await new Promise((r) => setTimeout(r, 30));
+ }
+ if (cancelled) return;
+ turnstileRef.current = await window.SpeechifyTurnstile!.render("#turnstile-container");
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ async function getToken() {
+ const t = turnstileRef.current;
+ if (!t) return null;
+ const token = await t.getToken();
+ t.reset();
+ return token;
+ }
+
+ return (
+
+
+ Voice in a React app
+
+ One component, <SpeechifyVoice>, turns any text into speech. The API
+ key stays on the server — the button just calls your own route handler.
+
+
+
+
+
Text
+
+
+
+
Voice
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/demos/react-tts-component/components/SpeechifyVoice.tsx b/demos/react-tts-component/components/SpeechifyVoice.tsx
new file mode 100644
index 0000000..81d688b
--- /dev/null
+++ b/demos/react-tts-component/components/SpeechifyVoice.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { useRef, useState } from "react";
+
+type Props = {
+ /** The text to speak. */
+ text: string;
+ /** Server route that proxies Speechify so the API key stays server-side. */
+ endpoint?: string;
+ /** A Speechify voice id (see platform.speechify.ai). */
+ voiceId?: string;
+ /** Button label when idle. */
+ label?: string;
+ /**
+ * Optional token provider for abuse-gated deployments (e.g. Cloudflare
+ * Turnstile). Return null to send the request unauthenticated.
+ */
+ getToken?: () => Promise;
+};
+
+type State = "idle" | "loading" | "playing" | "error";
+
+/**
+ * Adds a voice to any React app. Give it text, it plays that text as speech.
+ * The whole component is client-side; the Speechify API key never touches the
+ * browser because synthesis goes through your own `endpoint` route handler.
+ */
+export function SpeechifyVoice({
+ text,
+ endpoint = "/api/speak",
+ voiceId = "geffen_32",
+ label = "▶ Play",
+ getToken,
+}: Props) {
+ const audioRef = useRef(null);
+ const [state, setState] = useState("idle");
+
+ async function speak() {
+ if (!text.trim() || state === "loading") return;
+ setState("loading");
+ try {
+ const headers: Record = { "content-type": "application/json" };
+ const token = getToken ? await getToken() : null;
+ if (token) headers["x-turnstile-token"] = token;
+
+ const res = await fetch(endpoint, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ text, voiceId }),
+ });
+ if (!res.ok) throw new Error(await res.text());
+
+ const { audio } = (await res.json()) as { audio: string };
+ const el = audioRef.current!;
+ el.src = `data:audio/mpeg;base64,${audio}`;
+ el.onended = () => setState("idle");
+ await el.play();
+ setState("playing");
+ } catch {
+ setState("error");
+ }
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/demos/react-tts-component/demo.json b/demos/react-tts-component/demo.json
new file mode 100644
index 0000000..4c903b1
--- /dev/null
+++ b/demos/react-tts-component/demo.json
@@ -0,0 +1,6 @@
+{
+ "order": 200,
+ "title": "Voice in a React app",
+ "stack": "Next.js",
+ "blurb": "A drop-in <100-line React component that speaks any text. Type, hit play, hear it — the API key stays server-side in a route handler."
+}
diff --git a/demos/react-tts-component/next.config.ts b/demos/react-tts-component/next.config.ts
new file mode 100644
index 0000000..aefa820
--- /dev/null
+++ b/demos/react-tts-component/next.config.ts
@@ -0,0 +1,13 @@
+import type { NextConfig } from "next";
+
+const basePath = "/react-tts-component";
+
+const nextConfig: NextConfig = {
+ // The Speechify SDK is server-only; keep it out of the client bundle.
+ serverExternalPackages: ["@speechify/api"],
+ // Mounted as a Vercel Service under this subpath on demos.speechify.ai.
+ // Standalone (`npm run dev`) also serves under this prefix.
+ basePath,
+};
+
+export default nextConfig;
diff --git a/demos/react-tts-component/package.json b/demos/react-tts-component/package.json
new file mode 100644
index 0000000..0981215
--- /dev/null
+++ b/demos/react-tts-component/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "react-tts-component",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "Drop-in React component that adds a Speechify voice to any app",
+ "scripts": {
+ "dev": "next dev -p 8767",
+ "build": "next build",
+ "start": "next start -p 8767",
+ "lint": "next lint",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@speechify/api": "^3.0.1",
+ "next": "^16.2.10",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "typescript": "^5.6.0"
+ }
+}
diff --git a/demos/react-tts-component/tsconfig.json b/demos/react-tts-component/tsconfig.json
new file mode 100644
index 0000000..637ff2c
--- /dev/null
+++ b/demos/react-tts-component/tsconfig.json
@@ -0,0 +1,41 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "ES2022"
+ ],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": [
+ "./*"
+ ]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b4e8265..af9336e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -40,6 +40,34 @@ importers:
specifier: ^5.6.0
version: 5.9.3
+ demos/react-tts-component:
+ dependencies:
+ '@speechify/api':
+ specifier: ^3.0.1
+ version: 3.0.1
+ next:
+ specifier: ^16.2.10
+ version: 16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ react:
+ specifier: ^19.2.0
+ version: 19.2.7
+ react-dom:
+ specifier: ^19.2.0
+ version: 19.2.7(react@19.2.7)
+ devDependencies:
+ '@types/node':
+ specifier: ^22.0.0
+ version: 22.20.1
+ '@types/react':
+ specifier: ^19.2.0
+ version: 19.2.17
+ '@types/react-dom':
+ specifier: ^19.2.0
+ version: 19.2.3(@types/react@19.2.17)
+ typescript:
+ specifier: ^5.6.0
+ version: 5.9.3
+
site: {}
packages:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index dd085e3..b8c97c7 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -8,3 +8,4 @@
packages:
- "site"
- "demos/next-voice-cloning-app"
+ - "demos/react-tts-component"
diff --git a/site/public/index.html b/site/public/index.html
index c1ded39..456a1cd 100644
--- a/site/public/index.html
+++ b/site/public/index.html
@@ -276,7 +276,7 @@
-
+
@@ -368,7 +368,7 @@ FAQ