ElevenLabs + React in Practice: Building a Voice-Enabled UI

Build a voice-enabled React UI with ElevenLabs TTS and voice cloning in just a few dozen lines of code.
This article walks through integrating ElevenLabs TTS into a React app from scratch. It covers scaffolding with Vite, managing API keys via `.env`, wrapping fetch calls into a service layer, building a reusable `useElevenLabs` Hook for loading/error/audio state, and wiring up a `VoiceButton` component with Howler.js for end-to-end text-to-speech playback. It also covers the voice cloning workflow and key production pitfalls: mobile autoplay restrictions, rate limit debouncing, cloned voice readiness polling, and proxying API requests through Serverless functions to keep keys out of client bundles.
Why Add Voice to Your Web App
Giving a web application voice capabilities — whether to read out notifications, narrate articles, or build a fully conversational chatbot — used to be a niche and cumbersome engineering task. But with mature text-to-speech (TTS) services like ElevenLabs, combined with the composability of React Hooks, developers can integrate high-quality voice into a frontend UI in just a few dozen lines of code.
This article walks you through building a simple yet extensible voice-enabled UI from scratch: a React app that sends text to ElevenLabs and plays back the generated audio, a reusable useElevenLabs Hook that encapsulates the API calls, and how to plug in custom cloned voices for a more personalized experience.

What Makes ElevenLabs Stand Out
ElevenLabs has become a go-to voice service for developers for three core reasons: multilingual support, fine-grained control over voice style, and most importantly, Voice Cloning — upload a short audio sample of a speaker and generate speech that sounds nearly identical to the original.
From an engineering perspective, its API is straightforward — REST + JSON — and pairs well with browser-side fetch calls. This means you can keep all logic on the client during development (at the cost of exposing your API key), while in production you should proxy requests through your own backend. This is an important security consideration that runs throughout this article and will come up again.
Technically speaking, ElevenLabs uses deep learning-based Neural TTS, which is fundamentally different from traditional concatenative or parametric synthesis. Traditional approaches tend to sound mechanical, whereas Neural TTS, trained on large-scale corpora, captures fine-grained prosodic features like intonation, pauses, and stress — bringing synthesized speech much closer to human naturalism. ElevenLabs' voice cloning goes even further: using a small amount of voice samples (typically 30 seconds to a few minutes), it encodes the target speaker's timbral characteristics into the model via transfer learning, then combines that with the base TTS model's language understanding to generate high-fidelity speech. This is also known as zero/few-shot voice synthesis. Given how convincingly it can replicate voices, ElevenLabs' terms of service explicitly require users to only clone their own voice or one they have authorization for — compliance and privacy risks are worth keeping in mind when integrating this into real products.
Environment Setup and Dependencies
First, make sure you have Node.js (v16 or higher) installed, then use Vite to quickly scaffold a React project — its zero-config approach is perfect for demos:
npm create vite@latest elevenlabs-react-demo --template react
cd elevenlabs-react-demo
npm install
Next, install the dependencies needed for audio playback. We'll use howler as a cross-browser audio library:
npm install --save-dev eslint-plugin-react
npm install howler
Create a .env file in the project root to store your API key (never commit this to Git):
VITE_ELEVENLABS_API_KEY=your_api_key_here
Vite automatically exposes variables prefixed with VITE_ to client-side code — that's its convention.
Wrapping the ElevenLabs Service Layer
Encapsulating the raw fetch calls into a standalone utility module lets multiple components share the same logic and makes it easy to swap in a backend proxy later.
// src/services/elevenlabs.js
const API_KEY = import.meta.env.VITE_ELEVENLABS_API_KEY;
const BASE_URL = "https://api.elevenlabs.io/v1";
export async function generateSpeech({
text,
voiceId = "EXAVITQu4vr4xnSDxMaL", // Default: Rachel voice
stability = 0.75,
similarityBoost = 0.75,
}) {
const response = await fetch(`${BASE_URL}/text-to-speech/${voiceId}`, {
method: "POST",
headers: {
"xi-api-key": API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
text,
voice_settings: { stability, similarity_boost: similarityBoost },
}),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error?.message ?? "ElevenLabs request failed");
}
const { audio_url } = await response.json();
return audio_url;
}
Two parameters worth understanding here: stability and similarity_boost. Together they determine how natural and emotionally varied the voice sounds — 0.75 is a reasonable default balance. To use a cloned voice, simply upload a sample in the ElevenLabs dashboard, grab the new voiceId, and pass it in.
Understanding what these parameters actually do helps you dial in the right feel for your use case. stability (range 0–1) controls the consistency of the generated speech: higher values produce more stable, emotionally flat output — good for broadcast-style narration; lower values introduce more randomness and emotional variation, better suited for conversational or narrative content. similarity_boost (range 0–1) controls how closely the output hews to the original cloned voice sample: setting it too high can sometimes introduce artifacts or reduce audio quality, so even when aiming for high similarity, it's worth starting around 0.75. The two parameters have a somewhat inverse relationship — high stability combined with high similarity boost can actually produce a monotone result. In practice, run a few comparison tests with your target voice before settling on final values.
Building a Reusable Hook
React Hooks are a natural fit for managing async state. The useElevenLabs hook below centralizes the service call, loading state, and error handling, exposing a clean interface to consumers.
import { useState } from "react";
import { generateSpeech } from "../services/elevenlabs";
export function useElevenLabs() {
const [loading, setLoading] = useState(false);
const [audioUrl, setAudioUrl] = useState(null);
const [error, setError] = useState(null);
const speak = async ({ text, voiceId }) => {
setLoading(true);
setError(null);
try {
const url = await generateSpeech({ text, voiceId });
setAudioUrl(url);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
return { speak, loading, audioUrl, error };
}
The benefit of this layered design is clear: business components don't need to know anything about fetch internals — they just call speak() and read three state values: loading, audioUrl, and error.
Building the VoiceButton Component
With the hook in place, you can quickly build a UI component that lets users type text and hear it read aloud. The key is using useEffect to watch for changes to audioUrl — whenever a new URL arrives, play it via Howler and clean up on unmount to avoid memory leaks.
import { useState, useEffect } from "react";
import { useElevenLabs } from "../hooks/useElevenLabs";
import { Howl } from "howler";
export default function VoiceButton({ voiceId }) {
const [text, setText] = useState("");
const { speak, loading, audioUrl, error } = useElevenLabs();
useEffect(() => {
if (!audioUrl) return;
const sound = new Howl({
src: [audioUrl],
format: ["mp3"],
html5: true,
});
sound.play();
return () => sound.unload();
}, [audioUrl]);
const handleSubmit = (e) => {
e.preventDefault();
if (!text.trim()) return;
speak({ text, voiceId });
};
return (
<div style={{ maxWidth: "400px", margin: "auto" }}>
<form onSubmit={handleSubmit}>
<textarea rows={3} value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit" disabled={loading}>
{loading ? "Speaking…" : "Speak"}
</button>
</form>
{error && <p style={{ color: "red" }}>{error}</p>}
</div>
);
}
This component can be dropped anywhere in your app. Pass a custom voiceId if you have a cloned voice; otherwise the default voice works out of the box. Since the API call signature is identical, switching voices requires no other code changes.
The reason for choosing Howler.js over the native <audio> element or the Web Audio API comes down to cross-browser compatibility and developer ergonomics. The Web Audio API is powerful but low-level — playing a piece of audio requires manually managing an AudioContext, BufferSourceNode, and other objects, plus handling each browser's autoplay policy differences. The native <audio> element requires binding a URL to the DOM, which gets verbose when managed with ref in React. Howler internally abstracts both mechanisms with graceful fallback (preferring Web Audio API, falling back to HTML5 Audio), and handles common edge cases like Safari's autoplay restrictions and audio format fallbacks (e.g., mp3/ogg). The html5: true option in the code forces HTML5 Audio mode, which is more stable for streaming remote URLs directly — avoiding the latency of Web Audio API needing to download and decode the entire audio buffer into memory first.
The Full Voice Cloning Workflow
Voice cloning delivers a powerful personalization experience, but requires a few extra steps:
- Create a voice: Upload a ~30-second sample of the target speaker via the ElevenLabs dashboard or the
/v1/voicesAPI endpoint. - Get the voice_id: Record the voice ID returned by the API.
- Store the config: Save that ID in your app's configuration or fetch it dynamically from your backend.
Once you have the ID, pass it straight to the component: <VoiceButton voiceId="my_custom_voice_id" />. One important note: after uploading a sample, ElevenLabs needs a few seconds to process it. You should poll the voice status endpoint until is_ready is true before using the new voice_id.
Common Pitfalls and Gotchas
A few high-frequency issues are worth guarding against before they bite you:
- Audio won't play on mobile: Safari blocks autoplay that isn't triggered by user interaction. Fortunately, a button click counts as user interaction — just don't preload audio and you'll be fine.
- Rate limit errors: The free tier limits requests per minute. Cache previously generated URLs, or debounce rapid successive submissions.
- Cloned voice has a delay: Always wait for processing to complete before making a call, otherwise you'll get an unready voice.
- API key storage: This is the most critical security concern. In production, proxy requests through a Serverless function (such as Vercel or Cloudflare Workers) — never expose your key in client-side bundles.
On the topic of proxying API requests through Serverless functions in production: using Vercel as an example, create a Node.js function in your project's /api directory (e.g., api/tts.js). The function reads ELEVENLABS_API_KEY from Vercel's environment variables, forwards the text and voiceId sent by the frontend to ElevenLabs, then sends the returned audio URL or binary stream back to the client. On the frontend, simply change the direct ElevenLabs call to hit your own /api/tts endpoint instead. The benefit: the API key only exists in the server-side runtime environment — it never appears in bundled JS files and can't be seen in the browser's Network panel. Cloudflare Workers work similarly but run on edge nodes for lower latency. One important caveat: your proxy layer should also include authentication (e.g., verifying user login state or request origin), otherwise anyone can call your proxy endpoint and drain your ElevenLabs quota.
Wrapping Up
Integrating voice used to be considered a niche capability, but with ElevenLabs and React Hooks combined, high-quality voice output is achievable in just a few dozen lines of code. The pattern demonstrated here scales well: swap in different text, switch the voiceId, and you've got a foundation that covers accessibility features, chatbots, content narration, and more. For frontend developers, this is a low-barrier, high-payoff capability to add to your toolkit. Before going to production, make sure you've properly handled API key security — it's essentially the only serious engineering risk you need to address.
Related articles

Uber Deploys ChatGPT Work Company-Wide: AI in the Workplace Enters the Scaled Implementation Phase
Uber deploys ChatGPT Work to 30,000+ employees, extending it from engineering to all roles. We analyze its expert-engineer pairing, end-to-end automation, and questions of AI control and power concentration—insights for scaling AI in the workplace.

Shift at Midnight Hands-On: When "ID Verification" Becomes Alien Hunting
Shift at Midnight blends ID verification with horror survival in a four-player co-op game. We break down its inspection mechanics and chaotic humor.

Zelensky Recounts the Outbreak of War: I Need Ammunition, Not a Ride
Zelensky recalls the first day of war in a YouTube interview: farewell to family, rejecting Biden's evacuation offer with "I need ammunition, not a ride," and how Bucha shaped his stance on compromise and peace talks.