Purpose
Search Meetup for upcoming events matching a topic and location (and any of Meetup's filter dimensions) and return the matching events as structured JSON, plus the region-wide total and a pagination cursor so the caller knows the returned slice is partial. Read-only — never RSVP, join, save, or sign in. The entire first page of results is server-rendered into the /find/ page's HTML, so no scripted clicking, scrolling, or GraphQL reverse-engineering is needed for the common case.
When to Use
- "Find AI events in San Francisco", "book clubs in Brooklyn", "climbing meetups near 94110".
- Filtered discovery: a topic category, a date window (today / this weekend / next week), in-person vs online, a distance radius, free vs paid, sort by date/distance.
- Monitoring a city + topic for new upcoming events on a schedule.
- A Meetup search URL (
https://www.meetup.com/find/?...) you want decoded into structured data.
Workflow
The optimal path is a single browserless_agent navigation to the /find/ page through a residential proxy. The page is a server-rendered Next.js app: the complete eventSearch GraphQL connection (results, total count, and pagination cursor) plus every normalized Event / Group object is embedded in a <script id="__NEXT_DATA__"> JSON blob before any JS runs. Read that blob off the DOM and you have the data — no scripted clicking or GraphQL reverse-engineering for the common case. (Meetup sits behind Cloudflare; a residential proxy is required — a bare request from a datacenter IP risks a 403/challenge.)
Build the search URL. Base:
https://www.meetup.com/find/?source=EVENTS. Append the filters the caller asked for (param names below; all are URL query params):Dimension Param Value Topic keywords keywordsfree text, e.g. AI,book clubLocation locationslug form us--ca--San Francisco(URL-encoded); free textSan Francisco, CAalso geocodes server-side. For lat/lon uselat=+lon=.Category categoryIdnumeric taxonomy id (see Gotchas table) Date window dateRangetoday,tomorrow,this_week,this_weekend,next_week; orcustomStartDate=+customEndDate=(ISO)Format eventTypeinPerson,online(omit for both)Distance distancemiles from center (e.g. distance=10)Event type eventType(fee)paid/ free is implicit; price not filterable via URLSort sortFieldRELEVANCE(default),DATETIME(date),DISTANCENavigate through a residential proxy. Issue a
browserless_agentcall carrying the top-level argproxy: { proxy: "residential" }and a single{ "method": "goto", "params": { "url": "<url>", "waitUntil": "load", "timeout": 45000 } }(nevernetworkidle). The session persists across calls, keyed byproxy/profile, so keep the goto and the extract below in the samecommandsarray to save round-trips and avoid dropping the session config.Extract
__NEXT_DATA__in-page. Read the blob straight off the DOM with{ "method": "evaluate", "params": { "content": "(()=>JSON.stringify(JSON.parse(document.querySelector('#__NEXT_DATA__').textContent)))()" } }— the result comes back under.value. Better still, walk to the Apollo store (next step) inside the sameevaluateand return only the projected fields; the full blob can be large and the text return is ~200k-char capped.Read the Apollo store.
props.pageProps.__APOLLO_STATE__is a normalized cache:ROOT_QUERYhas a key beginningeventSearch(...)→{ totalCount, pageInfo { hasNextPage, endCursor }, edges[] }.totalCountis the region-wide total;endCursor(base64, e.g."MTI="= "12") is the pagination cursor.- Each
edges[i].node.__refis a string like"Event:314707414". Look that key up in__APOLLO_STATE__for the full event. - Each Event's
group.__ref("Group:<id>") resolves to the hosting group'sname+urlname. ROOT_QUERYalso carrieslocationSearch({"query":"..."})— the geocoded center (lat/lon/zip/timeZone/name) Meetup resolved the location text to. Use it to confirm the search landed in the right city.
Map each Event to output (field names as they appear in the Apollo
Eventobject):id,title,dateTime(start, ISO 8601 w/ tz),description(full body),eventType(PHYSICAL|ONLINE|HYBRID),eventUrl(canonical).venueis inline on the Event:{ name, address, city, state, country }for in-person; for online eventsvenueis null/empty and the platform link lives only on the event detail page.maxTickets= capacity;rsvps.totalCount= RSVP count;feeSettings === null⇒ free (a non-nullfeeSettingscarries the price).featuredEventPhoto/displayPhotoarePhotoInfo:<id>refs (resolve in the store for the image URL);seriesdescribes recurrence;socialProofInsights.totalInterestedUsersis the "interested" count.
Emit JSON in the Expected Output shape. Mark the slice partial whenever
pageInfo.hasNextPageis true and surfaceendCursorso the caller can paginate.
Pagination beyond page 1 (only if needed)
The SSR blob is page 1 (the first ~12 ranked events; the store often pre-hydrates up to ~30). For deeper pages the site's own client POSTs to the persisted-query GraphQL endpoint https://www.meetup.com/gql2 with { operationName, variables: { cursor }, extensions: { persistedQuery: { sha256Hash } } }. The hash + operation name rotate per front-end deploy and the endpoint is behind robots: Disallow: /gql*, so don't hardcode them — for most callers, re-fetching /find/ with a larger result window or a tightened filter (category/date/distance) is simpler and more durable than chasing the GraphQL cursor.
Handling a Cloudflare challenge
If the navigation gets challenged or rate-limited, escalate within the same browserless_agent call by prepending a { "method": "solve", "params": { "type": "cloudflare" } } command before the goto, then read the same blob:
{ "method": "goto", "params": { "url": "<find-url>", "waitUntil": "load", "timeout": 45000 } }(withproxy: { proxy: "residential" }on the call).{ "method": "text", "params": { "selector": "script#__NEXT_DATA__" } }or anevaluateon that selector — NOT{ "method": "snapshot" }; the data lives in a script tag, not the accessibility tree, so snapshot returns nothing useful.- Parse
__NEXT_DATA__exactly as in steps 4–5 above.
Verified working end-to-end (find page returned HTTP 200, full event set extracted) across two iterations.
Site-Specific Gotchas
Cloudflare — proxy is mandatory, stealth helps. Meetup fronts everything with Cloudflare. The
/find/navigation succeeds with abrowserless_agentresidential proxy (proxy: { proxy: "residential" }); if it's still challenged, add asolve { type: "cloudflare" }command. A datacenter IP risks a403/ "Just a moment" challenge. In a clean traced run only a single.woff2font request 403'd and one telemetry beacon 422'd — the find-page document itself returned 200, so don't mistake noisy sub-resource failures for a block; check the status of the/find/?...document specifically.Read the blob off the live DOM, not a text envelope. A raw HTTP fetch used to print an
Update available:banner you had to strip beforeJSON.parse; with abrowserless_agentyou readdocument.querySelector('#__NEXT_DATA__').textContentdirectly viaevaluate. Wrap the projection inJSON.stringify(...)(the return arrives under.value) and project in-page — the text return is ~200k-char capped, so don't ship the whole blob.The data is in a
<script>tag, not the DOM you'd click. Read#__NEXT_DATA__via anevaluate(or{ "method": "text", "params": { "selector": "script#__NEXT_DATA__" } }); a{ "method": "snapshot" }returns no useful refs for results — the accessibility tree doesn't include script contents.endCursoris base64."MTI="decodes to"12"— it's an opaque offset cursor, not the count.totalCountis the real region-wide total.__APOLLO_STATE__contains moreEvent:*keys than the search returned. A search for AI in SF yieldedtotalCount: 30but ~54Event:objects in the store — the extras are sparse stubs (id,dateTime,grouponly) referenced by group cards / series, NOT search hits. Only treat the refs inside theeventSearchconnection'sedges[]as results; iterating everyEvent:*key pollutes the output with non-matching events.Category taxonomy is a numeric enum (pass via
categoryId=). Confirmed from the live front-end bundle:Category id Category id Technology 546 Music 395 Career & Business 405 Health & Wellbeing 511 Art & Culture 521 Sports & Fitness 482 Science & Education 436 Social Activities 652 Hobbies & Passions 571 Games 535 Community & Environment 604 Identity & Language 622 Movements & Politics 642 Religion & Spirituality 593 Travel & Outdoor 684 Parents & Family 673 Pets & Animals 701 Support & Coaching 449 Dancing 612 Writing 467 Meetup's live taxonomy is finer-grained than the prompt's 15-bucket list (e.g. "Outdoors & Adventure" maps to Travel & Outdoor 684; "Arts & Culture" → Art & Culture 521). Verified
categoryId=546returns Technology events.Location resolution.
location=accepts both the slug form (us--ca--San Francisco) and free text (San Francisco, CA); both geocode server-side, and the resolved center appears underROOT_QUERY.locationSearch(...). With no location, results geolocate to the proxy/request IP — always pass an explicitlocation(orlat/lon) for deterministic output. "Online" searches still report a nominal city in the header but theeventType=onlinefilter scopes the results.No end time / duration / venue lat-lon in the SSR blob. The
/find/query selectsdateTime(start) but not end time, duration, or venue coordinates. For those, plus online-platform links (Zoom/Meet) and rich group fields (member count, About, founded date), fetch the individual event page or group page (each has its own richer__APOLLO_STATE__). The search-pageGroupobject is sparse:name,urlname,timezone, rating stats only.gql2is a persisted-query endpoint behind robots. Don't try to call it with a raw GraphQL query string — it expects anextensions.persistedQuery.sha256Hashthat rotates per deploy. The SSR blob already contains page 1, so you rarely need it.robots.txtdisallows the search query params (keywords,location,distance,dateRange,categoryId,sortField, …) and/gql*. This is read-only extraction of public listing data; throttle politely (≤1 req/s) and prefer the single SSR fetch over hammering the GraphQL cursor.
Expected Output
{
"success": true,
"query": {
"topic": "AI",
"location": "San Francisco, CA",
"category_id": null,
"sort": "RELEVANCE"
},
"resolved_center": {
"city": "San Francisco",
"state": "CA",
"country": "us",
"lat": 37.78,
"lon": -122.42,
"zip": "94101",
"timezone": "US/Pacific"
},
"total_count": 30,
"has_next_page": true,
"end_cursor": "MTI=",
"events": [
{
"id": "314707414",
"title": "John Vervaeke - How Minds Find What Matters",
"description": "For this session, we'll be reading John Vervaeke … relevance realization …",
"start_time": "2026-06-03T18:00:00-07:00",
"event_type": "PHYSICAL",
"is_online": false,
"venue": {
"name": "The Fold",
"address": "3359 26th St, San Francisco, CA 94110, USA",
"city": "San Francisco",
"state": "CA",
"country": "us"
},
"rsvp_count": 50,
"capacity": 50,
"is_free": true,
"price": null,
"photo_url": null,
"interested_count": 40,
"is_recurring": true,
"group": {
"name": "San Francisco Philosophy Reading Group",
"urlname": "sf-philosophy-reading-group",
"url": "https://www.meetup.com/sf-philosophy-reading-group/",
"rating": 4.81
},
"event_url": "https://www.meetup.com/sf-philosophy-reading-group/events/314707414/"
}
],
"error_reasoning": null
}Online-event shape (in-person fields null, event_type: "ONLINE"; platform link is only on the event detail page):
{
"id": "313346768",
"title": "Weekly AI Paper Discussion",
"start_time": "2026-06-05T18:00:00-07:00",
"event_type": "ONLINE",
"is_online": true,
"venue": null,
"online_platform": null,
"rsvp_count": 18,
"capacity": null,
"is_free": true,
"group": {
"name": "SF AI",
"urlname": "sfbay-ai",
"url": "https://www.meetup.com/sfbay-ai/"
},
"event_url": "https://www.meetup.com/sfbay-ai/events/313346768/"
}Blocked / failure shape:
{
"success": false,
"query": { "topic": "AI", "location": "San Francisco, CA" },
"total_count": null,
"events": [],
"error_reasoning": "Cloudflare challenge / HTTP 403 on /find/ — retry with a residential-proxy browserless_agent plus a solve command, or the residential proxy IP is flagged."
}