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 getting to the actual tweets (tweets.data.data) and paging through results trips up newcomers constantly.
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
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 required:
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:
Your First Search: client.v2.search()
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:
// 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 does results.data.data have a double .data? The paginator's .data property is the *raw API response*, shaped like { data: Tweet[], meta: {...}, includes: {...} }. So .data.data is the tweet array nested inside that response. Stick with results.tweets in application code, and 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:
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:
// 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. That's 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:
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:
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.
Other Recipes You Will Need Next
Search is rarely the only call. These are the four that follow it in almost every project.
Pull a user's timeline
const timeline = await roClient.v2.userTimeline('44196397', { max_results: 100, 'tweet.fields': ['created_at', 'public_metrics'], exclude: ['retweets', 'replies'], }); for (const tweet of timeline.tweets) console.log(tweet.text);
Note that userTimeline takes a numeric user id, not a handle. Resolving the handle first costs you an extra call.
Resolve handles to ids
const user = await roClient.v2.userByUsername('elonmusk', { 'user.fields': ['public_metrics', 'created_at', 'description'], }); console.log(user.data.id, user.data.public_metrics.followers_count); // Up to 100 at once const many = await roClient.v2.usersByUsernames(['elonmusk', 'vitalikbuterin']);
Read followers
const followers = await roClient.v2.followers('44196397', { max_results: 1000, 'user.fields': ['public_metrics'], asPaginator: true, });
Followers is one of the tightest limits on the official API: 15 requests per 15 minutes on most tiers, so a 100k-follower account takes hours to page through.
Filter by engagement, and why it silently fails
This is the trap that costs people the most time:
// Looks right. Does NOT work on API v2. const results = await roClient.v2.search('solana min_faves:100');
min_faves:, min_retweets: and min_replies: work in the web search box but are silently ignored by API v2. No error, no warning, just unfiltered results. Your options are to filter client-side after fetching (paying for every tweet you throw away), or to use a provider whose search speaks the full web operator set.
Common Errors and Fixes
401 Unauthorized: bad or missing Bearer token, or your app lacks read access. Regenerate the token and double-check 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: you're rate-limited. Read the x-rate-limit-reset header and back off with jitter.results.data.data is undefined: the search matched nothing, so 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.
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:
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?
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, and you'd rather not wrestle with paginators, OAuth apps, and a $200/month minimum, XCROP gets you there in one curl.
Frequently Asked Questions
What is tweets.data.data in twitter-api-v2?
It is the raw payload of the current page inside the paginator object. client.v2.search() returns a TweetSearchRecentV2Paginator, not an array: results.data is the API response and results.data.data is the TweetV2 array inside it. In application code you almost always want results.tweets instead, which flattens every page fetched so far.
Why is data.data undefined after a search?
The search matched zero tweets. When nothing matches, result_count is 0 and results.data.data can be undefined, so check results.meta.result_count before you index into it.
How far back can client.v2.search() go?
Seven days. Recent search only covers the last week; anything older needs full-archive search, which sits behind the Pro or Academic access tier.
How many tweets can you get per call?
One hundred at most, set through max_results, then you paginate. Call results.fetchNext() for one more page or results.fetchLast(1000) to keep fetching toward a target count.
Does twitter-api-v2 handle rate limits for you?
No. The library surfaces the rate-limit headers and throws on a 429, but the backoff is yours to write. Read x-rate-limit-reset and wait for the window rather than retrying immediately.
Why does min_faves not work in API v2 search?
min_faves, min_retweets and min_replies work in the web search box but are silently ignored by API v2. There is no error, just unfiltered results, so you either filter client-side after fetching or use a provider whose search accepts the full web operator set.
One API for X/Twitter data: profiles, tweets, followers, search and real-time streams. Start free with 5,000 credits/month, no card required.