TranscriptFetch
Back to blog
Tutorial·Jul 8, 2026·12 min read

How to Fetch YouTube Transcripts with a YouTube Transcript API in Node.js (TypeScript examples)

Copy paste Node and TypeScript snippets to fetch single, batch, and channel YouTube transcripts using an OSS library or the TranscriptFetch API.

Chandler Caseyby Chandler Casey

What you will build

This tutorial shows two production ready ways to work with a YouTube transcript API in Node.js. You will copy paste TypeScript 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, then ship. If you prefer a UI first, try our quick tool at /youtube-transcript-generator.

Which YouTube transcript API Node.js path should I pick?

Here is a quick comparison to help you decide before diving into code.

CapabilityOSS library in Node.jsTranscriptFetch API
Setupnpm install, no auth for transcriptsOne API key, documented REST
CoveragePublic videos with available captionsPublic videos, robust fallbacks, optional language handling
Batch and channelYou implement queuing and retriesBuilt to handle scale with fewer moving parts
Failure modesSite changes can break selectorsStable API surface, monitored uptime
Language selectionLibrary option flagsParameters, auto language selection
Rate limitsNone on library, limited by YouTubePredictable API limits, see /pricing

If you want to move fast with fewer edge cases, use the API path. If you prefer zero vendor dependencies, the OSS route is solid for small to medium jobs.

Prerequisites

Tip: keep keys in your shell environment. For example, export YT_API_KEY and TRANSCRIPTFETCH_API_KEY.

Option A: Open source only, with TypeScript

We will use an OSS transcript library 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/node

Create 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-limit controls parallelism so you do not spike network I/O.
  • Simple exponential backoff makes batch runs more resilient.

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 to collect video IDs.

// 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);
})();

References: the YouTube Data API overview is at https://developers.google.com/youtube

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/api-reference for full parameters and response shapes.

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 are predictable 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 terms and gives you consistent transcript results.

// channel-api.ts
import { google } from 'googleapis';
import pLimit from 'p-limit';

const TF_API = 'https://api.transcriptfetch.com/v1';

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') {
  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}`);
  const data = await res.json() as { text: string };
  return data.text;
}

async function channelToNdjson(channelId: string) {
  const ids = await listChannelVideoIds(channelId);
  const limit = pLimit(8);
  for (const id of ids) {
    try {
      const text = await limit(() => tf(id));
      process.stdout.write(JSON.stringify({ id, text }) + '\n');
    } catch (e) {
      process.stdout.write(JSON.stringify({ id, error: (e as Error).message }) + '\n');
    }
  }
}

(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');
  await channelToNdjson('UC_x5XG1OV2P6uZZ5FSM9Ttw');
})();

Tip: writing NDJSON lets you resume easily if the job stops halfway. Feed the output into a database or an embedding pipeline.

Short answers to common questions

  • What is a YouTube transcript API? A programmatic interface that returns the caption tracks for a YouTube video as structured text with timestamps. Both the OSS library and TranscriptFetch fit this definition.
  • Do I need the YouTube Data API to get a single transcript? No. You need it only to discover video IDs at scale, for example to enumerate a channel’s uploads.
  • What do I get back? Arrays of timed segments like { text, start, duration } and often a convenience full text field.
  • Can I use this with LLMs? Yes. After you have text, send it to your model for summarization or Q and A. The OpenAI API docs are at https://platform.openai.com/docs

Turning segments into clean paragraphs

Both approaches return segment arrays. Here is a tiny utility to reflow lines into paragraphs, merge short fragments, and strip bracketed stage directions.

export function segmentsToParagraphs(
  segments: { text: string; start: number; duration: number }[],
  maxGapMs = 1200
): string[] {
  const out: string[] = [];
  let buf: string[] = [];
  let lastEnd = 0;
  for (const s of segments) {
    const start = s.start;
    const end = s.start + s.duration;
    const clean = s.text.replace(/\[[^\]]+\]/g, '').trim();
    if (!clean) continue;
    if (buf.length && start - lastEnd > maxGapMs) {
      out.push(buf.join(' '));
      buf = [];
    }
    buf.push(clean);
    lastEnd = end;
  }
  if (buf.length) out.push(buf.join(' '));
  return out;
}

Production notes and guardrails

  • Respect quotas. The YouTube Data API has per day allotments, set sensible concurrency limits when listing channel items.
  • Handle missing captions. Some videos have no transcripts, plan for nulls and partial failure.
  • Persist checkpoints. For large channels, store the last processed video ID so you can resume safely.
  • Monitor for content changes. If you use the OSS path, pin library versions and add smoke tests.
  • Keep secrets out of code. Use environment variables or a secrets manager.

Where to go next

You now have two complete paths to implement a YouTube transcript API in Node.js with TypeScript. Pick the one that fits your team’s constraints, paste in the code, and start shipping transcript powered features today.

How to Fetch YouTube Transcripts with a YouTube Transcript API in Node.js (TypeScript examples) · TranscriptFetch