Back to blog
Tutorial · Jul 8, 2026 · 12 min read · Updated Aug 21, 2026

Fetch YouTube Transcripts in Node.js with API

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. 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.

CapabilityOSS library in Node.jsTranscriptFetch API
Setupnpm install, no separate auth for public transcript fetchingOne API key, documented REST
CoveragePublic videos with available captionsPublic videos, see endpoint docs for parameters and response shape
Batch and channelYou implement queuing and retriesHosted API, simpler client code
Failure modesUpstream site changes or missing captions can break runsConsistent HTTP responses, see /docs
Language selectionLibrary option flagsParameters documented in /docs/endpoints
Rate limitsNo library-level quota, but practical upstream constraints still applySee /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

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

bash
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

ts
// 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

ts
// 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 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.

ts
// 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 flattened text field 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

ts
// 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

ts
// 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.

ts
// 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 segments when you care about timestamps, clip creation, semantic search windows, or grounded citations back to the source video.
  • Store flattened text when 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.