+ Integration Guide

· Updated August 28, 2026

Twitter/X API with Node.js: Complete Guide

Search X/Twitter, get tweet details, fetch user profiles, and pull follower lists with Node.js and the FetchLayer API. Working code for every endpoint.

Written by Alex P.

  • Node.js
  • twitter scraping
  • X API
  • Twitter API
  • JavaScript
  • API integration

This guide shows you how to use the FetchLayer Twitter/X API with Node.js. Every example uses the built-in fetch API (Node 18+) — no external HTTP libraries needed.

If you prefer a fully typed SDK, FetchLayer now ships @fetchlayer/twitter on npm with an open-source GitHub repo at fetchlayer-dev/twitter-scraper-js.


Setup

  1. Get a free API key (no credit card)
  2. That’s it. No npm packages to install — unless you want the official SDK:
npm install @fetchlayer/twitter

All FetchLayer endpoints use POST with a JSON body and return JSON.

const API_KEY = 'ss-your-api-key';
const BASE_URL = 'https://api.fetchlayer.dev/twitter';

async function twitter(endpoint, body) {
  const res = await fetch(`${BASE_URL}/${endpoint}`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });

  if (!res.ok) {
    throw new Error(`FetchLayer ${res.status}: ${await res.text()}`);
  }

  return res.json();
}

Search Twitter/X

Find tweets matching a keyword across X/Twitter:

// Search by keyword (Latest tab)
const results = await twitter('search', {
  query: 'best project management tools',
  product: 'Top',
  count: 10,
});

for (const tweet of results.results) {
  console.log(`[${tweet.likeCount} likes] @${tweet.author.handle}: ${tweet.text.slice(0, 100)}`);
}
// Search for people/accounts
const people = await twitter('search', {
  query: 'startup founder',
  product: 'People',
  count: 20,
});

// Search media
const media = await twitter('search', {
  query: 'new feature launch',
  product: 'Media',
  count: 10,
});

// Paginate results
const page1 = await twitter('search', { query: 'React', product: 'Latest', count: 25 });
const page2 = await twitter('search', { query: 'React', product: 'Latest', count: 25, cursor: page1.cursor });

Get a Tweet by ID

const tweet = await twitter('tweet-detail', {
  tweetId: '1942939879222220800',
});

console.log(`@${tweet.author.handle}: ${tweet.text}`);
console.log(`${tweet.likeCount} likes · ${tweet.retweetCount} retweets · ${tweet.replyCount} replies`);

Get Replies to a Tweet

const replies = await twitter('tweet-replies', {
  tweetId: '1942939879222220800',
});

for (const reply of replies.replies) {
  console.log(`  @${reply.author.handle}: ${reply.text.slice(0, 100)}`);
}

Get a User Profile

const profile = await twitter('user-profile-details', {
  handle: 'openai',
});

console.log(`${profile.displayName} (@${profile.handle})`);
console.log(`Followers: ${profile.followersCount.toLocaleString()}`);
console.log(`Following: ${profile.followingCount.toLocaleString()}`);
console.log(`Tweets: ${profile.tweetsCount.toLocaleString()}`);
console.log(`Bio: ${profile.description}`);

Get Extended Profile Info

const about = await twitter('about-profile', {
  handle: 'openai',
});

console.log(`Category: ${about.category}`);
console.log(`Business: ${about.isBusiness}`);

Get a User’s Tweets

const data = await twitter('user-tweets', {
  handle: 'rauchg',
  count: 20,
});

for (const tweet of data.tweets || []) {
  console.log(`[${tweet.likeCount || 0} likes] ${tweet.text.slice(0, 120)}`);
}

Get Followers and Following

// Who a user follows
const following = await twitter('following', {
  handle: 'openai',
  count: 50,
});

for (const account of following.accounts || []) {
  console.log(`@${account.handle} — ${(account.followersCount || 0).toLocaleString()} followers`);
}

// Who follows a user
const followers = await twitter('followers', {
  handle: 'openai',
  count: 50,
});

// Verified followers only
const verified = await twitter('verified-followers', {
  handle: 'openai',
  count: 20,
});

Full Example: Twitter Monitoring Script

A practical example — monitor a keyword on Twitter and identify high-engagement tweets:

const API_KEY = process.env.FETCHLAYER_API_KEY;
const BASE_URL = 'https://api.fetchlayer.dev/twitter';

async function twitter(endpoint, body) {
  const res = await fetch(`${BASE_URL}/${endpoint}`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });
  return res.json();
}

async function monitor(keyword) {
  const data = await twitter('search', {
    query: keyword,
    product: 'Latest',
    count: 25,
  });

  const results = data.results || [];
  console.log(`\nFound ${results.length} tweets for "${keyword}"`);

  // Sort by engagement
  const top = results
    .sort((a, b) => (b.likeCount || 0) - (a.likeCount || 0))
    .slice(0, 5);

  console.log('\nTop 5 tweets:');
  for (const tweet of top) {
    console.log(`  [${tweet.likeCount} likes] @${tweet.author.handle}: ${tweet.text.slice(0, 120)}`);
    console.log(`  ${tweet.url}\n`);
  }
}

monitor(process.argv[2] || 'your-product');
FETCHLAYER_API_KEY=ss-your-key node monitor.mjs "react server components"

Handling Errors and Rate Limits

FetchLayer returns standard HTTP status codes: 401 for a missing or invalid API key, 400 when a required field like query or a handle is missing, and 429 once you exceed your plan’s request rate. Distinguish them instead of treating every non-OK response the same way:

async function twitter(endpoint, body, retries = 2) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    const res = await fetch(`${BASE_URL}/${endpoint}`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(body),
    });

    if (res.status === 429) {
      if (attempt === retries) throw new Error('Rate limited — back off and try again later');
      const wait = Number(res.headers.get('Retry-After') ?? 5);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    if (res.status === 401) throw new Error('Invalid API key');
    if (res.status === 400) throw new Error(`Bad request to /${endpoint}: ${await res.text()}`);
    if (!res.ok) throw new Error(`API error ${res.status}: ${await res.text()}`);

    return res.json();
  }
}

A 429 mid-run is expected behavior on the free tier, not a bug — the retry above respects Retry-After rather than hammering the endpoint again immediately.


Paginating follower lists safely

Follower and following lists are cursor-paginated, and they’re the endpoints most likely to surprise you on cost — an account with 500,000 followers will return cursors long after you meant to stop. Always bound the loop:

async function collectFollowers(handle, { maxPages = 5, perPage = 100 } = {}) {
  const collected = [];
  let cursor;

  for (let page = 0; page < maxPages; page++) {
    const data = await twitter('user-followers', {
      handle,
      count: perPage,
      ...(cursor ? { cursor } : {}),
    });

    const batch = data.followers ?? [];
    collected.push(...batch);

    // Two independent stop conditions: no cursor returned, or an empty
    // page. Relying on only the cursor can loop on some edge cases.
    if (!data.nextCursor || batch.length === 0) break;
    cursor = data.nextCursor;
  }

  return collected;
}

Each page is a billed request, so maxPages is a cost control, not just a safety net. For most analysis — checking whether two accounts share an audience, or finding notable followers — a sample of the first few pages answers the question. Exhaustive enumeration rarely does anything a sample doesn’t.


Deduplicating with node:sqlite

Node 22+ ships SQLite in core, so a monitoring job can deduplicate without pulling in a dependency:

import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync('twitter.sqlite');
db.exec(`
  CREATE TABLE IF NOT EXISTS tweets (
    id         TEXT PRIMARY KEY,
    handle     TEXT NOT NULL,
    text       TEXT NOT NULL,
    likes      INTEGER,
    seen_at    INTEGER NOT NULL
  )
`);

const insert = db.prepare(`
  INSERT OR IGNORE INTO tweets (id, handle, text, likes, seen_at)
  VALUES (?, ?, ?, ?, ?)
`);

function recordNew(tweets) {
  const fresh = [];
  for (const t of tweets) {
    const { changes } = insert.run(
      t.id,
      t.author?.handle ?? '',
      t.text,
      t.likeCount ?? 0,
      Date.now(),
    );
    // changes === 0 means this tweet ID was already stored.
    if (changes > 0) fresh.push(t);
  }
  return fresh;
}

INSERT OR IGNORE against a primary key does the deduplication in the database. Tweets have stable IDs, which makes this cleaner than platforms where you have to fingerprint on content.

If you’re on Node 20 or earlier, better-sqlite3 has the same synchronous API.


Graceful shutdown

A long-running monitor killed mid-write can leave a partial record. Handle the signal and finish the current iteration:

let shuttingDown = false;

process.on('SIGTERM', () => { shuttingDown = true; });
process.on('SIGINT', () => { shuttingDown = true; });

async function monitorLoop(handles, intervalMs = 300_000) {
  while (!shuttingDown) {
    for (const handle of handles) {
      if (shuttingDown) break;
      try {
        const data = await twitter('user-tweets', { handle, count: 20 });
        const fresh = recordNew(data.tweets ?? []);
        if (fresh.length) console.log(`@${handle}: ${fresh.length} new`);
      } catch (err) {
        console.error(`@${handle} failed:`, err.message);
      }
    }

    // Sleep in short slices so a shutdown signal is noticed promptly
    // instead of after a full five-minute interval.
    for (let waited = 0; waited < intervalMs && !shuttingDown; waited += 1_000) {
      await new Promise((r) => setTimeout(r, 1_000));
    }
  }

  db.close();
  console.log('Shut down cleanly.');
}

The sliced sleep is the part worth copying. await setTimeout(300_000) means a container gets SIGKILLed five minutes after SIGTERM because it was still sleeping — and orchestrators usually only wait about thirty seconds before escalating.


What’s Next