Skip to main content
← Back to blog
by Engineering
tutorialtwitter-apinodejs

How to Search Tweets with twitter-api-v2 in Node.js (2026)

Searching Tweets with twitter-api-v2

twitter-api-v2 is the most popular Node.js/TypeScript client for the X (Twitter) API, and searching recent tweets is one of the first things most developers reach for. It's also where people hit the most friction — the method returns a *paginator*, not a plain array, so accessing the actual tweets (tweets.data.data) and paging through results trips up newcomers.

This guide walks through searching tweets with twitter-api-v2 end to end: authenticating, calling client.v2.search(), reading the results correctly, paginating, the query operators worth knowing, and the rate-limit/cost realities. At the end we look at a simpler alternative for when the official API's setup and pricing are more than your project needs.

Installing and Authenticating

BASH
npm install twitter-api-v2

Recent tweet search is app-only, so all you need is a Bearer token from the X developer portal — no user OAuth flow:

JAVASCRIPT
import { TwitterApi } from 'twitter-api-v2';

// App-only client from a Bearer token
const client = new TwitterApi(process.env.X_BEARER_TOKEN);

// Use the read-only sub-client for search/lookups
const roClient = client.readOnly;

Two things to know up front:

Recent search covers the last 7 days only. The full-archive endpoint requires an elevated (Pro/Enterprise or Academic) access level.
Your access tier determines your rate limits and how many tweets you can pull per month.
JAVASCRIPT
const results = await roClient.v2.search('javascript lang:en -is:retweet', {
  'tweet.fields': ['created_at', 'public_metrics', 'author_id'],
  max_results: 100, // 10–100 per page
});

The first argument is your query string (operators covered below). The options object mirrors the API's query params — tweet.fields, user.fields, expansions, start_time, end_time, and max_results (capped at 100 per request).

Reading the Results: the tweets.data.data Gotcha

client.v2.search() does not return an array of tweets. It returns a TweetSearchRecentV2Paginator. There are three correct ways to read it, and mixing them up is the single most common source of confusion:

JAVASCRIPT
// 1. Recommended — .tweets is every tweet fetched so far, as a flat array
for (const tweet of results.tweets) {
  console.log(tweet.id, tweet.text);
}

// 2. The RAW current-page payload — this is where "tweets.data.data" comes from
console.log(results.data.data);  // TweetV2[] for the current page
console.log(results.data.meta);  // { result_count, newest_id, next_token, ... }

// 3. Async iterator — automatically pages through results (mind rate limits)
for await (const tweet of results) {
  console.log(tweet.text);
}

Why results.data.data — the double .data? The paginator's .data property is the *raw API response*, whose shape is { data: Tweet[], meta: {...}, includes: {...} }. So .data.data is the tweet array inside that response. Prefer results.tweets in application code; reach for results.data.data only when you need the raw page exactly as the API returned it.

Guard against empty results. When a search matches nothing, result_count is 0 and results.data.data can be undefined:

JAVASCRIPT
if (!results.meta.result_count) {
  console.log('No tweets matched.');
} else {
  for (const tweet of results.tweets) console.log(tweet.text);
}

Pagination: Getting More Than One Page

Each request returns at most 100 tweets. The paginator handles the rest for you:

JAVASCRIPT
// Fetch one more page and merge it into results.tweets
await results.fetchNext();

// Keep fetching until you have ~1000 tweets (or the pages run out)
await results.fetchLast(1000);

console.log('Collected ' + results.tweets.length + ' tweets');
console.log('Exhausted?', results.done);

The async iterator (for await ... of results) pages automatically — convenient, but it will happily burn through your rate limit on a broad query, so cap it yourself.

Search Query Operators Worth Knowing

The query string is where most of the power lives:

OperatorMatches
`from:username`Tweets from a specific account
`to:username`Replies directed at an account
`#hashtag`A hashtag
`"exact phrase"`An exact phrase
`-keyword`Excludes a keyword
`lang:en`A specific language
`-is:retweet`Excludes retweets
`is:reply` / `-is:reply`Only / no replies
`has:media` / `has:links`Tweets containing media / links
`url:"github.com"`Tweets linking a domain

Combine them freely: from:vitalikbuterin (ethereum OR rollup) -is:retweet has:links.

Rate Limits and the Cost Problem

This is where teams get stuck. Recent search on the X API v2 is rate-limited per 15-minute window *and* capped by a monthly tweet-pull quota tied to your plan. In practice:

The Basic tier runs $200/month and still caps how many tweets you can retrieve.
Recent search only reaches back 7 days — historical work needs the pricier full-archive access.
Hitting the ceiling means 429 responses and building backoff, queueing, and quota tracking around them.

For a detailed breakdown see our guides on X API pricing and rate limits.

Common Errors and Fixes

401 Unauthorized — bad or missing Bearer token, or your app lacks read access. Regenerate the token and confirm the project's permissions.
403 Forbidden — your access tier can't use this endpoint (recent search needs Basic+, full-archive needs Pro/Academic).
429 Too Many Requests — rate-limited. Read the x-rate-limit-reset header and back off with jitter.
results.data.data is undefined — the search matched nothing; check results.meta.result_count before indexing.

A Simpler Alternative: XCROP

If the developer-app setup, the 7-day window, and the $200/month floor are more than your project needs, XCROP exposes the same tweet-search capability behind a single API key — no OAuth app, no Bearer-token juggling, and credit-based pricing that runs up to 50% cheaper than the official API.

BASH
curl -X POST https://xcrop.io/api/v2/search \
  -H "Authorization: Bearer xc_live_..." \
  -H "Content-Type: application/json" \
  -d '{"query": "javascript lang:en", "count": 100, "sort": "latest"}'

The response is a plain array of tweets — no paginator, no .data.data to unwrap:

JAVASCRIPT
const res = await fetch('https://xcrop.io/api/v2/search', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer ' + process.env.XCROP_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ query: 'javascript lang:en', count: 100, sort: 'latest' }),
});

const { data } = await res.json();
for (const tweet of data) console.log(tweet.text);

You can sort by latest, popular, or engagement, filter by min_likes / min_retweets / lang, and request up to 1,000 results in a single call — see every parameter in the interactive builder.

Which Should You Use?

twitter-api-v2 (official)XCROP
SetupDeveloper app + Bearer tokenOne API key
AuthOAuth / app-only tokenBearer key in a header
Results shapePaginator (`.tweets` / `.data.data`)Plain JSON array
Search window7 days (recent tier)Recent + trending coverage
Per-request cap100 tweetsUp to 1,000
PricingFrom $200/mo (Basic)Free tier, then credit-based
Best forDeep X-native integrationsFast data pulls without the API-tier tax

If you're building a product deeply tied to the X platform, the official twitter-api-v2 library is the right tool. If you just need clean tweet data — for a dashboard, a bot, research, or a side project — without wrestling paginators, OAuth apps, and a $200/month minimum, XCROP gets you there in one curl.

Build this with the XCROP API

One API for X/Twitter data — profiles, tweets, followers, search and real-time streams. Start free with 5,000 credits/month, no card required.