What you will build
This tutorial shows two production-ready ways to work with a YouTube Transcript API in Node.js. If you are searching for a YouTube transcript API Node.js workflow with TypeScript examples, this is the practical version: copy-paste snippets for real retrieval patterns developers actually need.
You will get examples for:
- A single video transcript
- Batch transcripts for a list of video IDs with concurrency and retries
- Channel-wide transcripts by enumerating uploads, then fetching captions
We will do each flow twice, first with an open source library, then with the TranscriptFetch API. By the end, you can choose the path that fits your stack and shipping constraints. If you prefer a UI-first option, try /youtube-transcript-generator.
YouTube Transcript API in Node.js: two approaches
Here is a quick comparison to help you decide before diving into code.
| Capability | OSS library in Node.js | TranscriptFetch API |
|---|---|---|
| Setup | npm install, no separate auth for public transcript fetching | One API key, documented REST |
| Coverage | Public videos with available captions | Public videos, see endpoint docs for parameters and response shape |
| Batch and channel | You implement queuing and retries | Hosted API, simpler client code |
| Failure modes | Upstream site changes or missing captions can break runs | Consistent HTTP responses, see /docs |
| Language selection | Library option flags | Parameters documented in /docs/endpoints |
| Rate limits | No library-level quota, but practical upstream constraints still apply | See /pricing for plan limits |
If you want fewer moving parts in your application code, use the API path. If you prefer zero vendor dependencies, the OSS route is solid for small to medium jobs.
Prerequisites
- Node.js 18 or newer, for native fetch and top-level await. See the Node.js API docs.
- TypeScript 5 in your project. See the TypeScript documentation.
- For channel-wide enumeration, a YouTube Data API key. Create one in Google Cloud and review the YouTube Data API overview.
- For the API path, a TranscriptFetch API key from your dashboard. See /docs.
Option A: open source only, with TypeScript
We will use the youtube-transcript package plus the official YouTube Data API client to list a channel’s videos.
Install packages
npm i youtube-transcript googleapis p-limit
npm i -D typescript ts-node @types/nodeCreate a tsconfig.json if you do not have one, then run with ts-node or build with tsc.
Single video transcript with youtube-transcript
// single-oss.ts
import { YoutubeTranscript } from 'youtube-transcript';
// Minimal type for convenience
type TranscriptItem = { text: string; duration: number; offset: number };
async function fetchSingle(videoIdOrUrl: string, lang = 'en') {
const items = await YoutubeTranscript.fetchTranscript(videoIdOrUrl, { lang }) as TranscriptItem[];
const text = items.map(i => i.text).join(' ').trim();
return { items, text };
}
(async () => {
const { text } = await fetchSingle('https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'en');
console.log(text.slice(0, 200) + '...');
})();Notes:
- Input can be a video ID or a full URL.
- The library returns an array of timed caption fragments. Joining on space gives a quick transcript string.
Batch transcripts with concurrency and retries
// batch-oss.ts
import { YoutubeTranscript } from 'youtube-transcript';
import pLimit from 'p-limit';
type TranscriptItem = { text: string; duration: number; offset: number };
async function fetchTranscriptSafe(videoId: string, lang = 'en', attempt = 1): Promise<string | null> {
try {
const items = await YoutubeTranscript.fetchTranscript(videoId, { lang }) as TranscriptItem[];
return items.map(i => i.text).join(' ').trim();
} catch (err) {
if (attempt < 3) {
await new Promise(r => setTimeout(r, 500 * attempt));
return fetchTranscriptSafe(videoId, lang, attempt + 1);
}
console.error(`Failed ${videoId}:`, err);
return null;
}
}
async function batchFetch(videoIds: string[], concurrency = 5) {
const limit = pLimit(concurrency);
const jobs = videoIds.map(id => limit(() => fetchTranscriptSafe(id)));
const results = await Promise.all(jobs);
return videoIds.map((id, i) => ({ id, text: results[i] }));
}
(async () => {
const ids = ['dQw4w9WgXcQ', '3JZ_D3ELwOQ'];
const out = await batchFetch(ids, 5);
console.log(out.filter(x => x.text).length, 'succeeded');
})();Key points:
p-limitcontrols parallelism so you do not spike network I/O.- Simple retry logic makes batch runs more resilient when some requests fail.
Channel-wide transcripts using the YouTube Data API
To fetch every upload for a channel, first get the uploads playlist ID from channels.list, then walk playlistItems.list to collect video IDs. This is the standard YouTube Data API pattern.
// channel-oss.ts
import { google } from 'googleapis';
import pLimit from 'p-limit';
import { YoutubeTranscript } from 'youtube-transcript';
type TranscriptItem = { text: string; duration: number; offset: number };
const youtube = google.youtube({ version: 'v3', auth: process.env.YT_API_KEY });
async function getUploadsPlaylistId(channelId: string): Promise<string> {
const res = await youtube.channels.list({ id: [channelId], part: ['contentDetails'] });
const details = res.data.items?.[0]?.contentDetails;
if (!details?.relatedPlaylists?.uploads) throw new Error('No uploads playlist');
return details.relatedPlaylists.uploads;
}
async function listAllUploads(uploadsPlaylistId: string): Promise<string[]> {
const ids: string[] = [];
let pageToken: string | undefined = undefined;
do {
const res = await youtube.playlistItems.list({
playlistId: uploadsPlaylistId,
part: ['contentDetails'],
maxResults: 50,
pageToken,
});
res.data.items?.forEach(it => {
const vid = it.contentDetails?.videoId;
if (vid) ids.push(vid);
});
pageToken = res.data.nextPageToken || undefined;
} while (pageToken);
return ids;
}
async function transcriptForId(id: string, lang = 'en') {
const items = await YoutubeTranscript.fetchTranscript(id, { lang }) as TranscriptItem[];
return items.map(i => i.text).join(' ').trim();
}
async function channelTranscripts(channelId: string) {
const uploads = await getUploadsPlaylistId(channelId);
const ids = await listAllUploads(uploads);
const limit = pLimit(5);
const texts = await Promise.all(ids.map(id => limit(async () => {
try { return { id, text: await transcriptForId(id) }; } catch { return { id, text: null as string | null }; }
})));
return texts;
}
(async () => {
if (!process.env.YT_API_KEY) throw new Error('Set YT_API_KEY');
const out = await channelTranscripts('UC_x5XG1OV2P6uZZ5FSM9Ttw'); // Google Developers
console.log('Videos:', out.length, 'First OK:', out.find(x => !!x.text)?.id);
})();What breaks in practice
This is the part many tutorials skip. Transcript retrieval usually works well, but there are still edge cases you need to handle in application code:
- Some public videos simply do not have captions available.
- Language availability can differ from what you expect, especially when only auto-generated captions exist.
- Private, deleted, or age-restricted videos can fail even if the video ID is valid.
- Output shape matters. For search, RAG, or summarization, keep timestamped
segments. For quick display or indexing, a flattenedtextfield is often enough. - Batch jobs should expect partial success, not all-or-nothing completion.
That is true whether you use open source scraping or an API wrapper. The difference is mostly where the operational complexity lives, in your code or behind the API.
Option B: TranscriptFetch API with Node and TypeScript
This path uses our hosted API so you do not maintain scraping logic or custom retry queues. See /docs/endpoints for parameters and response shapes, and /pricing for plan limits.
Get an API key and set up
- Create or find your key in the TranscriptFetch dashboard. See /docs.
- Export it:
export TRANSCRIPTFETCH_API_KEY=sk_live_123
We will use native fetch available in Node 18.
Single video transcript via TranscriptFetch
// single-api.ts
const TF_API = 'https://api.transcriptfetch.com/v1';
type TfSegment = { text: string; start: number; duration: number };
type TfResponse = {
videoId: string;
language: string;
segments: TfSegment[];
text: string; // convenience joined text
};
async function tfSingle(videoId: string, lang = 'en'): Promise<TfResponse> {
const res = await fetch(`${TF_API}/transcripts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.TRANSCRIPTFETCH_API_KEY}`,
},
body: JSON.stringify({ source: 'youtube', videoId, language: lang }),
});
if (!res.ok) throw new Error(`API ${res.status}`);
return res.json();
}
(async () => {
if (!process.env.TRANSCRIPTFETCH_API_KEY) throw new Error('Set TRANSCRIPTFETCH_API_KEY');
const out = await tfSingle('dQw4w9WgXcQ', 'en');
console.log(out.text.slice(0, 200) + '...');
})();Why this is simpler:
- One HTTP call returns normalized segments and a ready-to-use text field.
- Errors come back as HTTP statuses, which simplifies control flow.
Batch transcripts with concurrency, retries, and partial results
// batch-api.ts
import pLimit from 'p-limit';
const TF_API = 'https://api.transcriptfetch.com/v1';
type TfResponse = { videoId: string; language: string; segments: { text: string; start: number; duration: number }[]; text: string };
type BatchResult = { id: string; ok: boolean; text?: string; error?: string };
async function tfFetch(videoId: string, lang = 'en', attempt = 1): Promise<BatchResult> {
try {
const res = await fetch(`${TF_API}/transcripts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.TRANSCRIPTFETCH_API_KEY}`,
},
body: JSON.stringify({ source: 'youtube', videoId, language: lang }),
});
if (res.status >= 500 && attempt < 3) {
await new Promise(r => setTimeout(r, 500 * attempt));
return tfFetch(videoId, lang, attempt + 1);
}
if (!res.ok) return { id: videoId, ok: false, error: `HTTP ${res.status}` };
const data = await res.json() as TfResponse;
return { id: videoId, ok: true, text: data.text };
} catch (e: any) {
if (attempt < 3) {
await new Promise(r => setTimeout(r, 500 * attempt));
return tfFetch(videoId, lang, attempt + 1);
}
return { id: videoId, ok: false, error: e?.message || 'error' };
}
}
async function tfBatch(ids: string[], concurrency = 8) {
const limit = pLimit(concurrency);
const jobs = ids.map(id => limit(() => tfFetch(id)));
const results = await Promise.all(jobs);
const ok = results.filter(r => r.ok).length;
console.log(`Completed ${ok}/${ids.length}`);
return results;
}
(async () => {
if (!process.env.TRANSCRIPTFETCH_API_KEY) throw new Error('Set TRANSCRIPTFETCH_API_KEY');
const out = await tfBatch(['dQw4w9WgXcQ', '3JZ_D3ELwOQ', 'invalid123']);
console.log(out);
})();Channel transcripts end to end with TranscriptFetch
We will reuse the same YouTube listing code as in the OSS option, then fan out TranscriptFetch calls. This keeps your channel enumeration within Google’s documented API pattern and gives you a consistent transcript response shape.
// channel-api.ts
import { google } from 'googleapis';
import pLimit from 'p-limit';
const TF_API = 'https://api.transcriptfetch.com/v1';
type ChannelTranscriptResult =
| { id: string; ok: true; text: string }
| { id: string; ok: false; error: string };
async function listChannelVideoIds(channelId: string, apiKey = process.env.YT_API_KEY) {
const youtube = google.youtube({ version: 'v3', auth: apiKey });
const ch = await youtube.channels.list({ id: [channelId], part: ['contentDetails'] });
const uploads = ch.data.items?.[0]?.contentDetails?.relatedPlaylists?.uploads;
if (!uploads) throw new Error('No uploads playlist');
const ids: string[] = [];
let pageToken: string | undefined = undefined;
do {
const res = await youtube.playlistItems.list({
playlistId: uploads,
part: ['contentDetails'],
maxResults: 50,
pageToken,
});
res.data.items?.forEach(it => {
const vid = it.contentDetails?.videoId;
if (vid) ids.push(vid);
});
pageToken = res.data.nextPageToken || undefined;
} while (pageToken);
return ids;
}
async function tf(videoId: string, lang = 'en'): Promise<ChannelTranscriptResult> {
try {
const res = await fetch(`${TF_API}/transcripts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.TRANSCRIPTFETCH_API_KEY}`,
},
body: JSON.stringify({ source: 'youtube', videoId, language: lang }),
});
if (!res.ok) {
return { id: videoId, ok: false, error: `HTTP ${res.status}` };
}
const data = await res.json() as { text: string };
return { id: videoId, ok: true, text: data.text };
} catch (e: any) {
return { id: videoId, ok: false, error: e?.message || 'error' };
}
}
async function fetchChannelTranscripts(channelId: string, concurrency = 8) {
const ids = await listChannelVideoIds(channelId);
const limit = pLimit(concurrency);
const results = await Promise.all(
ids.map(id => limit(() => tf(id)))
);
const ok = results.filter(r => r.ok).length;
console.log(`Fetched ${ok}/${ids.length} transcripts`);
return results;
}
(async () => {
if (!process.env.YT_API_KEY) throw new Error('Set YT_API_KEY');
if (!process.env.TRANSCRIPTFETCH_API_KEY) throw new Error('Set TRANSCRIPTFETCH_API_KEY');
const out = await fetchChannelTranscripts('UC_x5XG1OV2P6uZZ5FSM9Ttw');
console.log(out.slice(0, 3));
})();Which output should you store
For most applications, the right storage choice is simple:
- Store
segmentswhen you care about timestamps, clip creation, semantic search windows, or grounded citations back to the source video. - Store flattened
textwhen you only need indexing, summarization, or lightweight display. - If you can afford it, store both. The text is convenient, and the segments preserve context you often wish you had later.
That is one of the practical advantages of a normalized API response. You can keep one downstream shape across single-video, batch, and channel workflows.
Decision rubric
Pick the OSS route if:
- You want minimal direct cost
- You are comfortable handling caption availability issues yourself
- Your workloads are small enough that custom retry and batching logic is acceptable
Pick the API route if:
- You want the shortest path from input video ID to usable transcript output
- You want one response shape for app code, storage, and downstream AI pipelines
- You prefer documented request parameters and simpler error handling
If your next step is implementation, start with the YouTube Transcript API. If your next step is understanding request and response details, go straight to /docs/endpoints.
