Written by Trevor Indrek Lasn @ trevorlasn.com
--- title: You have been invited to the 0xInsider.com Discord server! description: Join the 0xInsider.com Discord community — talk markets, odds, and predictions with fellow prediction market traders in real time. topics: ['prediction markets', 'tech'] ---
# You have been invited to the 0xInsider.com Discord server!
We just opened up a Discord server for prediction market enthusiasts, and we'd love for you to be part of it.
It's a space to discuss market moves, debate odds, share forecasts, and swap strategies with other traders. Whether you want to dig into a specific market or just see what everyone's watching, there's a spot for you.
### What You'll Find Here
- **Real-time market talk** — react to moves on Polymarket and Kalshi as they happen
- **Odds and forecast debates** — argue your read, pressure-test someone else's
- **Strategy swaps** — share what's working and learn what isn't
- **Whale trade discussion** — break down the large trades [0xInsider](https://www.0xinsider.com) surfaces
Whether you're deep into a specific market or just want to see what everyone's watching, there's a spot for you.
### Join Us
Ready to talk markets, odds, and predictions with fellow traders?
[Join the 0xInsider.com Discord](https://discord.gg/JxJsnbeYhn)
See you inside!
---
--- title: Ph.D in failure, Masters in getting back up description: Every time I bet on myself instead of taking a paycheck, and what each one taught me. topics: ['reflections'] ---
# Ph.D in failure, Masters in getting back up
I've failed a lot, more than most people would care to admit, and I'm proud of it. Everything below is something I bet on myself to build, and most of it didn't work. That's the point. Each failure taught me something a steady paycheck never could. My day jobs as an engineer and manager aren't on this list, only what I built for myself.
**2010-2013 · pro gaming.** Semi-pro League of Legends. I started on top lane, then switched to jungle and climbed to around 2750 elo in Season 2, good enough to solo queue against pros and scrim with pros and semi-pros. I wanted to make it work, but the pieces never quite fit. Pro gaming was an opaque path back then with no clear way in, and I couldn't make a convincing case for it. My parents didn't see it as a real career, and I wasn't able to explain it well enough to change their minds. By Season 3 I put more into school, and that was that. It didn't pan out as a career, but that's around when I started learning to code. **Lesson:** talent without a path to get paid is just a hobby. I was early to a real industry, but being early without the conviction to back it is the same as being wrong.
**2015-2022 · writing.** Writing tech articles, tutorials, and listicles, paid through the Medium Partner Program. That was the gig, and for a while it actually paid the bills. Then Medium lost ground in tech and the payouts dried up. The platform got flooded with low-effort, mass-produced filler, and the good writing drowned in the noise. **Lesson:** if you build your income on a platform you don't own, you're renting it. When Medium's economics changed, mine did too, and I had no vote.
**2018 · startup 1.** A recipe site (lol). I wanted to learn Meteor.js and scratch my own itch: I wanted to cook more but never knew what to make, so I built a site to browse recipes. I shut it down not long after. The space was saturated with established brands that already owned the search rankings, and I had no edge to take them on. **Lesson:** know how it makes money before you build it. A content site needs enormous traffic just to clear minimum wage, and I'd confused shipping something with building a business.
**2018 · startup 2.** Linktree, but for crypto addresses. I branded it at the time as one payment link for all your crypto: you put every address under a single link and shared that. I had no idea how to monetize it and wasn't business-savvy enough to figure it out. **Lesson:** a free utility anyone can clone is a feature, not a company. "Useful" and "fundable" are different questions, and I never asked the second.
**2019 · startup 3.** A mashup of LinkedIn and Crunchbase. One place to look up a person and the company behind them at once: who works where, who raised what, who to reach out to. Handy for sales and recruiting on paper. In reality the data was a pain to keep fresh, I had no distribution, and I drifted off before working out who would actually pay for it. **Lesson:** reselling data you don't own is a treadmill, not a moat. The build was never the hard part. Distribution and a reason to pay were, and I'd skipped both.
**2022 · startup 4.** Reddit, but for podcasts. You could submit podcast links, upvote and downvote them, and leave comments, so the best episodes would rise to the top instead of getting buried in someone's feed. I liked the idea of a community-curated front page for audio. It never reached the critical mass a voting site needs: with too few users there was nothing to vote on, and with nothing to vote on nobody stuck around. A classic cold-start problem, and I had no plan to solve it. **Lesson:** a community product is worthless until it's crowded, so the only question that matters is how you get the first thousand people. I had no answer.
**2023 · startup 5.** Burned out on tech, so I pivoted to fashion design and launched two brands: a clothing label and a 3D-printed jewelry line. It actually went well for a while, but I shut both down. Not enough traction to justify keeping at it, and not enough passion to push through that. **Lesson:** work on something you care about deeply. Passion is the fuel for the unglamorous middle, and without it even a business that's working won't hold you.
**2023 · startup 6.** A text-based dating site. I was in a relationship at the time, but I still thought there was a market for something more conversation-first than the swipe apps. I built it and then never launched it, because I didn't want my name attached to the dating scene. **Lesson:** if you won't put your name on it, you've already decided. I just figured that out after building it instead of before.
**2024 · startup 7.** A hiring platform for developers. You could filter jobs by the tech stack you actually work in to find roles that fit, and build a profile so companies reached out to you instead of you applying to them. Too much competition in the space, and not enough passion on my end to outlast it. **Lesson:** entering a crowded market without an unfair edge is volunteering to lose slowly. "Better" isn't an edge, "different in a way that matters" is.
**2025 · startup 8.** A dashboard for engineering managers to run their org. It tracked the metrics that actually tell you how a team is doing: cycle time, how long code takes to ship, PR review turnaround, deployment frequency, change failure rate, and lead time for changes. I gave up before launch. Somewhere along the way I realized I wasn't passionate about the problem, and forcing it felt worse than walking away. **Lesson:** only build problems you'd happily live inside for a decade, because you'll have to. I quit before launch, and quitting early beats quitting late.
**2025 · startup 9.** You'd tell an AI what you wanted to learn, and it would point you to vetted resources and courses instead of the pile of low-effort tutorials out there. I built my own vector database stuffed with the good stuff to power the recommendations. I closed it because AI search got too good too fast: Perplexity, Claude, and Google now do the same thing well enough that there was no room to compete. **Lesson:** don't build in the path of where the giants are already heading. When your core feature is a roadmap item for Google and OpenAI, you're a placeholder, not a competitor.
**2026 · startup 10.** [0xinsider](https://0xinsider.com), the intelligence layer for prediction markets. A coworker got me into it. In a random conversation she mentioned her husband builds Polymarket bots, and I wanted to see what all the fuss was about. I went down the rabbit hole and never came back. I'm still building it, I love it, and I plan to be at this one for many years. After everything above, this is the one I'm all in on. **Lesson:** everything before this is why it's different. A market I'm obsessed with, a clear way to get paid, an edge in the data, and the conviction to stay. The failures weren't detours, they were tuition.
---
--- title: AEO and GEO for AI Overviews, ChatGPT, Claude, Gemini, and Perplexity description: What Answer Engine Optimization and Generative Engine Optimization mean, and how to get your site cited by AI Overviews, ChatGPT, Claude, Perplexity, and Gemini. topics: ['webdev', 'tech'] ---
# AEO and GEO for AI Overviews, ChatGPT, Claude, Gemini, and Perplexity
Search results don't look the way they did two years ago. Google now opens with AI Overviews, ChatGPT and Claude pull live web results into their answers, Perplexity built an entire product around it, and Gemini sits one tap away inside every Google surface. The page is no longer the destination. The page is a source the model is reading on your behalf.
Two acronyms have shown up to describe the work of being visible inside those answers. **AEO** stands for Answer Engine Optimization, which is the work of being the source an "answer engine" uses when it returns a direct answer instead of a list of links. **GEO** stands for Generative Engine Optimization, which is the same idea framed around generative AI specifically: appearing inside answers a model writes from scratch using your page as a reference.
Google's own [AI optimization guide](https://developers.google.com/search/docs/fundamentals/ai-optimization-guide) treats both as variations of regular SEO. From their perspective, "optimizing for generative AI search is optimizing for the search experience, and thus still SEO." The ranking and quality systems that decide what shows up in a list of blue links are the same systems that decide what shows up inside an AI Overview. Improving for one improves the other.
One reason this matters: each AI surface pulls from a different web index, but most of those indexes are downstream of the same crawl, rendering, and quality work.
```
Your page
│
▼
Search engine indexes (where the crawl lands)
┌────────────────────────────────────────────────────────┐
│ Google index ──▶ Google Search, AI Overviews, Gemini │
│ Bing index ──▶ Bing, Microsoft Copilot │
│ OpenAI index ──▶ ChatGPT Search │
│ Anthropic + Brave ▶ Claude web search │
│ Perplexity + Bing ▶ Perplexity answers │
└────────────────────────────────────────────────────────┘
│
▼
The AI surface reads from the index, cites your page
```
The practical question is what you do to your site. The rest of this post walks through it, with sources for every recommendation.
### Eligibility comes before everything else
Before any of the content work matters, the page has to be allowed to appear in AI features at all. Google's guide is explicit: a page is only eligible for AI features if it's eligible to appear as a regular search snippet ([source](https://developers.google.com/search/docs/fundamentals/ai-optimization-guide)). That means the URL needs to be indexed, the page needs to be crawlable in `robots.txt`, snippets need to be allowed (no `nosnippet`, no `max-snippet:0`), and the content has to load without requiring the crawler to execute heavy JavaScript first.
Open Google Search Console and run a URL inspection on a page you care about. The "Test live URL" view shows you what Google sees, including the rendered HTML after JavaScript has executed. If the article body is missing from that rendered HTML, fix it before doing anything else. Google's [JavaScript SEO basics](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics) covers the patterns that work and the ones that break crawling. Server rendering and static generation are the safest bets.
A 30-second sanity check from your terminal, one per major AI crawler:
```bash
# Google Search (feeds AI Overviews and Gemini grounding)
curl -A "Googlebot/2.1 (+http://www.google.com/bot.html)" -I https://0xinsider.com/article
# Bing (feeds Microsoft Copilot)
curl -A "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)" -I https://0xinsider.com/article
# OpenAI search indexer (ChatGPT Search)
curl -A "OAI-SearchBot/1.3; +https://openai.com/searchbot" -I https://0xinsider.com/article
# Anthropic search indexer (Claude web search)
curl -A "Claude-SearchBot/1.0 (+https://www.anthropic.com)" -I https://0xinsider.com/article
# Perplexity indexer
curl -A "PerplexityBot/1.0 (+https://perplexity.ai/perplexitybot)" -I https://0xinsider.com/article
```
A `200 OK` from a spoofed user agent isn't proof that the real crawler can reach the page. Bot operators block UA spoofing, so the only authoritative check is to verify the request against published IP ranges or reverse-DNS records. Google documents its [crawler verification process](https://developers.google.com/search/docs/crawling-indexing/verifying-googlebot), and OpenAI, Anthropic, and Perplexity all publish IP ranges in their bot docs. Use the `curl` test to catch obvious blocks (a `403`, `503`, or login-page redirect that suggests Cloudflare's bot-fight rule or a misconfigured WAF), then confirm against the official IP list for the bots that matter to you.
The full list of bots worth thinking about, with what they do and the canonical reference:
```
Bot user agent Purpose Reference
──────────────────────────────────────────────────────────────────────────
Googlebot Google Search index developers.google.com/crawling/docs/crawlers-fetchers/google-common-crawlers
Google-Extended Gemini Apps + Vertex AI Grounding + developers.google.com/crawling/docs/crawlers-fetchers/google-special-case-crawlers
AI training (separate from Search)
Bingbot Bing index (feeds Copilot) bing.com/webmasters/help/which-crawlers-does-bing-use-8c184ec0
GPTBot/1.3 OpenAI training developers.openai.com/api/docs/bots
OAI-SearchBot/1.3 ChatGPT Search live index developers.openai.com/api/docs/bots
ChatGPT-User ChatGPT user-initiated fetch developers.openai.com/api/docs/bots
ClaudeBot Anthropic training support.claude.com/en/articles/8896518
Claude-SearchBot Anthropic search index (Claude search) support.claude.com/en/articles/8896518
Claude-User Claude user-initiated fetch support.claude.com/en/articles/8896518
PerplexityBot Perplexity search index docs.perplexity.ai/docs/resources/perplexity-crawlers
Perplexity-User Perplexity user-initiated fetch docs.perplexity.ai/docs/resources/perplexity-crawlers
Applebot-Extended Apple Intelligence training support.apple.com/en-us/119829
Meta-ExternalAgent Meta AI training developers.facebook.com/docs/sharing/webmasters/web-crawlers
CCBot Common Crawl (feeds many models) commoncrawl.org/ccbot
```
A `robots.txt` you can copy that allows AI search visibility (the surfaces that cite your page) while opting out of training (the bots that scrape for model training data):
```
# Allow indexing for search and AI search surfaces
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Perplexity-User
Allow: /
User-agent: Claude-SearchBot
Allow: /
User-agent: Claude-User
Allow: /
# Block AI training crawlers (does not affect Google Search inclusion)
User-agent: GPTBot
Disallow: /
User-agent: ClaudeBot
Disallow: /
User-agent: Google-Extended
Disallow: /
User-agent: Applebot-Extended
Disallow: /
User-agent: Meta-ExternalAgent
Disallow: /
User-agent: CCBot
Disallow: /
# Fallback
User-agent: *
Allow: /
Sitemap: https://0xinsider.com/sitemap.xml
```
The distinction matters. `GPTBot` and `ClaudeBot` are training crawlers, and blocking them does not affect search inclusion. `Google-Extended` is broader: it controls AI training **and** grounding inside Gemini Apps and Vertex AI Grounding, but does not affect Google Search ranking or AI Overview eligibility ([Google source](https://developers.google.com/crawling/docs/crawlers-fetchers/google-special-case-crawlers)). The bots that determine whether your page can show up inside an AI answer are the search indexers: `Googlebot`, `Bingbot`, `OAI-SearchBot`, `Claude-SearchBot`, and `PerplexityBot` ([OpenAI source](https://developers.openai.com/api/docs/bots), [Anthropic source](https://support.claude.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler), [Perplexity source](https://docs.perplexity.ai/docs/resources/perplexity-crawlers)). Many sites accidentally block one of those and tank their visibility.
Meta robots tags are the other lever, page-level rather than site-level:
```html
```
To opt out of `Google-Extended` (Gemini Apps and Vertex AI Grounding), use the `robots.txt` product token shown earlier. Google does not document `Google-Extended` as a robots meta tag, only as a `robots.txt` token ([source](https://developers.google.com/crawling/docs/crawlers-fetchers/google-special-case-crawlers)). The snippet directives above are documented in Google's [meta tags reference](https://developers.google.com/search/docs/crawling-indexing/special-tags).
```
Page exists
│
▼ robots.txt allows crawl? ── no ──▶ invisible
▼ page renders without JS errors? ── no ──▶ invisible
▼ indexed in Search Console? ── no ──▶ invisible
▼ snippet allowed (no nosnippet)? ── no ──▶ regular search only
▼ quality + originality signals? ── weak ─▶ ranked, rarely cited
│
▼
Eligible to appear in AI Overviews and related surfaces
```
Every layer is a gate. The fancier optimization work only matters once all the gates are open.
### What gets cited is what a model can't write from training data alone
Generative search rewards specificity. Models can summarize generic information without quoting anyone, so the pages that get cited are the ones that say something the model can't synthesize on its own. Google's guide tells creators to focus on "unique, valuable, people-first content" rather than commodity content that re-states what every other page on the topic already says ([source](https://developers.google.com/search/docs/fundamentals/ai-optimization-guide)). The deeper version of this advice lives in Google's [helpful content guidance](https://developers.google.com/search/docs/fundamentals/creating-helpful-content), which goes into how to demonstrate firsthand experience, real expertise, and original perspective.
Here are two real versions of the same paragraph for an article about migrating to Next.js 16. Same topic, same word count, wildly different odds of being cited:
```
Commodity version:
"Next.js 16 introduces async params, making route parameters
asynchronous. This is a breaking change you should plan for
when upgrading from Next.js 15. Make sure to await your params
in dynamic routes."
Distinctive version:
"We migrated a 240-route Next.js 15 app to 16 last week. The
async params change broke 47 pages in CI on the first run.
The mechanical fix: wrap every `params.slug` access in
`await params`. The catch we hit: dynamic API routes that
destructure params in the function signature need the
signature itself marked async, not just the body. Took
3 hours, almost all of it search-replace."
```
A model can produce the commodity version from training data alone, so it won't cite the source. There's nothing in there it couldn't write itself. The distinctive version has a number (47 broken pages), a specific catch (the function signature subtlety), and a time estimate (3 hours), none of which the model can generate without quoting the source. Even one of those details is often enough to flip a page from "training data summary" to "cited reference".
```
What the model sees about your topic
│
├──▶ Commodity content
│ "Same overview 50 other pages have"
│ │
│ ▼
│ Model synthesizes from training data
│ │
│ ▼
│ Not cited
│
└──▶ Distinctive content
"Specific data, screenshot, opinion,
result you tested in production"
│
▼
Model can't synthesize, must quote
│
▼
Cited in the answer
```
### Clean technical structure helps the crawler and the model
Semantic HTML matters. Use real heading levels in a sensible hierarchy, put the answer to the question the page is about near the top, and avoid burying content under preamble. A real before/after on the same blog post:
```html
How to migrate to Next.js 16
A practical guide
We migrated 240 routes last week...
How to migrate to Next.js 16
A practical guide to async params,
Turbopack defaults, and the gotchas we hit.
Async params, in practice
We migrated 240 routes last week...
```
The second version gives the crawler clear structure (`article`, `h1`, `section`, `h2`) and the model clean boundaries for what's heading, lede, and body.
Google's documentation on [page experience](https://developers.google.com/search/docs/appearance/page-experience) explains how Core Web Vitals feed into ranking, which feeds directly into AI feature eligibility. The thresholds Google publishes ([source](https://web.dev/articles/vitals)):
```
Metric Good Poor
─────────────────────────────────────────────────────
LCP Largest Contentful Paint ≤ 2.5s > 4.0s
INP Interaction to Next Paint ≤ 200ms > 500ms
CLS Cumulative Layout Shift ≤ 0.1 > 0.25
```
The numbers ranking algorithms look at are the 28-day field data from real Chrome users (CrUX), not a Lighthouse run on your laptop. Read them from `web-vitals` in JavaScript to align local testing with what Google's systems see:
```js
onLCP(metric => console.log('LCP', metric.value, metric.rating));
onINP(metric => console.log('INP', metric.value, metric.rating));
onCLS(metric => console.log('CLS', metric.value, metric.rating));
```
The AI optimization guide also pushes back on several "optimization hacks" circulating online. Adding an `llms.txt` file is not a ranking signal and isn't used by Google's AI features ([source](https://developers.google.com/search/docs/fundamentals/ai-optimization-guide)). Chunking content into tiny sections or rewriting every heading as a question is unnecessary, because models read context across the whole page. The guide also says structured data is useful where it powers a documented rich result, but it isn't required for AI feature visibility. Spend the time on real content quality and rendering instead.
```
What the crawler fetches
─────────────────────────────────────────────────────────
Server-rendered HTML Client-only SPA shell
───────────────────── ─────────────────────
Title
Real content...
```
Test it inside Google's [Rich Results Test](https://search.google.com/test/rich-results) before deploying. The tool will tell you exactly which required fields are missing or malformed.
If you run a local business or sell products, two unrelated surfaces matter more than schema. A verified [Google Business Profile](https://support.google.com/business/answer/3038063) feeds local AI answers with your hours, location, services, and reviews. A [Merchant Center](https://support.google.com/merchants/answer/188494) feed is what AI Overviews pull product information from. The AI optimization guide names both explicitly as the primary input for business and commerce results ([source](https://developers.google.com/search/docs/fundamentals/ai-optimization-guide)).
```
Type of result Source feed Where it shows
────────────────────────────────────────────────────────────────────
Local business ◀── Google Business Profile ──▶ Maps, local panel,
(hours, location, reviews) local AI answers
Products ◀── Merchant Center feed ────▶ Shopping cards,
(price, stock, variants) product AI answers
Recipes, FAQs, ◀── Schema.org JSON-LD ──────▶ Rich results,
events, articles (on-page structured data) AI understanding
```
### Agentic experiences are the next surface
The newer wrinkle is autonomous agents browsing on the user's behalf (Claude with computer use, ChatGPT Operator, Perplexity's assistant). Google's AI optimization guide recommends sites consider how agents interpret their DOM, controls, and content ([source](https://developers.google.com/search/docs/fundamentals/ai-optimization-guide)). Sites with confusing markup, hidden controls, or essential information rendered only as images are hard for agents to operate. The accessibility work you'd already do for screen readers covers most of the same ground.
A real before/after of an interactive control on a booking page:
```html
Confirm booking
```
The second version tells an agent three things: it's a submit button, the action is "Confirm booking", and the icon is decorative. The first version tells it nothing. An agent that can't identify the booking confirmation gives up and picks a site it can operate.
Form fields work the same way. An agent reads `name`, `id`, `aria-label`, and the surrounding `` element:
```html
Reservation time
```
Switching to `type="datetime-local"` is a tiny change that gives both browsers and agents a native datetime picker with structured value handling. No agent has to guess what format you want.
```
User intent: "Book me a table for 7pm tonight"
│
▼
Agent opens your site
│
▼
┌───────────────────────────────────────────────┐
│ Can it find the booking widget? │
│ Can it read the available time slots? │
│ Are the buttons labeled, not just icons? │
│ Does the form submit without a 3s JS stall? │
└───────────────────────────────────────────────┘
│
┌──────────────┴──────────────┐
▼ ▼
Task completes on Agent gives up,
your site picks a competitor
```
### Measure what you can, and don't chase what you can't
Search Console is still the source of truth for Google-side data. AI Overviews and AI Mode traffic is rolled into the standard Web performance report ([Google source](https://developers.google.com/search/docs/appearance/ai-features)), so impressions and clicks for the pages you care about are the right place to look. Bing Webmaster Tools provides the equivalent for Bing and Copilot.
One inference you can draw, carefully: filter Performance by Query containing a conversational starter (`how`, `what`, `why`, `is`, `can`). These long-tail queries are the kind AI Overviews trigger on, and a noticeable shift in impressions vs clicks on those queries is consistent with the page being summarized inside an AI answer rather than visited. It is not proof. Layout changes, ranking shifts, query mix changes, and seasonality can all produce similar patterns. Use it as a hypothesis to investigate, not a verdict.
A direct way to test whether models cite you: open each surface and ask a question your content should answer. Concrete, copy-paste tests:
```
ChatGPT (Search mode):
"What's a practical way to migrate a Next.js 15 app to Next.js 16?"
Claude (with web search):
"Find me a recent first-hand account of migrating to Next.js 16
on a large app. I want specifics, not generic advice."
Perplexity:
"Real-world Next.js 16 migration: what broke, how it was fixed."
Gemini (or Google with AI Overview):
"How do you handle async params when migrating to Next.js 16?"
```
If your domain shows up in the inline source list or the answer cites it, you're being retrieved. Repeat across the major surfaces every few weeks for the topics that matter to your business. Track the count of cite-events the same way you'd track backlinks.
```
What to track Where it lives Signal it gives
────────────────────────────────────────────────────────────────────
Impressions ◀── Search Console ──▶ Visibility growth
Clicks ◀── Search Console ──▶ Selection rate
Conversions ◀── Your own analytics ──▶ Business outcome
Cite events ◀── Ask ChatGPT / Claude ──▶ Whether models cite
What to skip:
"AI Overview rank trackers" no reliable public methodology yet
```
Doing the work above covers everything Google's [AI optimization guide](https://developers.google.com/search/docs/fundamentals/ai-optimization-guide) recommends and everything the other AI search surfaces reward. AEO and GEO aren't separate disciplines from SEO. They're the same work, applied with sharper attention to content originality, rendering, and the structured pipelines that feed every AI surface on the web.
---
--- title: Why I Chose Rust for 0xInsider description: No QA team. No on-call rotation. One person, one binary, one financial product. The compiler is my entire engineering org. topics: ['tech', 'webdev'] ---
# Why I Chose Rust for 0xInsider
I have no QA team. No on-call rotation. No second pair of eyes on my pull requests at 1am. I'm one person running a production financial terminal that tracks 7,000+ prediction market traders across Polymarket and Kalshi, streams every large trade in real time, and computes P&L analytics on millions of records. If a bug ships, there's nobody to page. If a data race corrupts a trader's P&L at 3am, I find out when a user opens a support ticket.
That's why I chose Rust. Not because it's trendy. Not because the benchmarks look good. Because when you're solo and the product handles financial data, the compiler has to be your engineering team — your QA, your code reviewer, your safety net. If it compiles, the concurrency is correct, every error path is handled, and every database query matches the schema. I push on Friday night and sleep fine.
I've been writing TypeScript for years. I started writing Rust seriously in October 2025, building trading bots for prediction markets. That work pulled me deep into the APIs, the on-chain data, the trading patterns — and eventually led to [0xInsider](https://0xinsider.com). It's an intelligence terminal for prediction markets: real-time trade feeds, trader analytics, performance grading, leaderboards, portfolio breakdowns. One Rust binary powers all of it.
Here's what that one binary actually does.
## One process, many subsystems
Everything runs in a single process, all the time.
```
main()
├─ HTTP server (user requests, hundreds of concurrent connections)
├─ sync_loop (10 concurrent trader syncs, 11-phase pipeline each)
├─ polymarket_websocket (primary real-time whale trade detection via RTDS)
├─ whale_trades_poller (60s backfill for trades missed during WS reconnects)
├─ periodic_tasks (rankings, materialized view refresh, classifiers)
├─ discovery_loop (daily wallet discovery, new trader detection)
├─ kalshi_websocket (live market data stream)
├─ sports_websocket (live market data stream)
├─ trade_flow_monitor (real-time flow analysis)
└─ export_worker (background dataset exports)
```
Each trader sync is an 11-phase pipeline — fetch activity from external APIs, parse and validate, upsert into a time-partitioned database, resolve market metadata, recompute market stats and P&L, fetch positions, classify the trader's strategy, compute derived metrics, backfill missed trades, run alerts, and reconcile resolved markets with integrity checks. Ten of those run simultaneously while the WebSocket ingests trades in real time and the HTTP server handles user requests without flinching.
In most languages, the risk is subtle. Two tasks touch the same state, one mutates it, and you get a data race that only manifests under production load at 2am. In Rust, if two tasks access shared state without proper synchronization, the code won't compile. Not "it might race condition" — it refuses to build. That's a fundamentally different contract. The concurrency bugs don't happen at runtime because they can't exist at compile time.
But the interesting part isn't the safety guarantees everyone talks about. It's the specific architectural patterns Rust enables that I couldn't replicate — or couldn't trust — in other languages.
## 190 SQL queries, zero runtime surprises
The backend has 190+ SQL query files and 247 database migrations. The schema changes constantly — I ship features almost daily, which means new columns, renamed fields, altered types. Every single query is verified against the real database schema at compile time using SQLx macros.
```rust title="src/traders/profile.rs"
let row = sqlx::query_file_as!(
TraderRow,
"queries/traders/get_trader_profile.sql",
address
)
.fetch_optional(&state.pool)
.await
.map_err(db_err!("fetching trader"))?;
```
The SQL lives in a separate `.sql` file. At compile time, SQLx reads it, connects to the database schema, and verifies every column name, every type, every parameter. The `TraderRow` struct must match the query's output exactly or it won't compile.
Rename a column in migration 248 and forget to update one of those 190 query files? `cargo build` fails. Not a runtime crash in production three days later when someone happens to hit the right endpoint. The build itself won't finish. For a solo developer shipping schema changes weekly, this is the difference between confidence and anxiety. I refactor the database schema the same way I'd refactor code — rename freely, change types, restructure joins — and the compiler tells me every file I missed.
No ORM does this. Prisma and TypeORM validate against the schema at codegen time, but they still rely on runtime deserialization. SQLx validates the exact query, the exact types, and the exact result shape — at compile time, with zero runtime overhead. It's the closest thing to a database type system I've found.
## External APIs lie
If you build on third-party data, you already know this. Fields change types without warning. An endpoint that returned JSON yesterday returns HTML today. A field that was always present stops showing up. Documentation says one thing, the actual response says another.
When you're computing financial metrics — P&L, position sizes, win rates — a mishandled field doesn't just cause a crash. It causes a wrong number. A `NaN` that sneaks into a P&L calculation. A trade size parsed as shares when it should be dollars. A null that silently becomes zero and wipes out someone's profit history. Those bugs are worse than crashes because nobody notices until the data is already wrong.
In Rust, Serde forces you to declare the exact shape you expect from every external response:
```rust title="src/polymarket_ws/parsing.rs"
#[derive(Debug, serde::Deserialize)]
pub struct RtdsTrade {
pub proxy_wallet: Option, // not always present
pub size: Option, // can be int or float
pub price: Option,
pub timestamp: Option,
}
```
Every field is `Option` because the API doesn't guarantee any of them. The code that consumes this struct has to handle the `None` case — the compiler won't let you pretend a field exists when it might not. No `undefined is not a function`. No `Cannot read property 'size' of null`. You deal with the messiness upfront, or your code doesn't compile.
This sounds like more work. It is — on the first day. After that, it's less work, because you never debug phantom `NaN`s in production. Every edge case is handled once, at parse time, and the compiler ensures nothing downstream ever sees unvalidated data.
## Every failure mode, named and handled
External APIs go down. They rate-limit you. They return garbage. A market that existed yesterday returns 404 today. When you're integrating with multiple exchanges that each have a dozen failure modes, "try/catch everything" isn't a strategy — it's a prayer. You'll forget one edge case at 2am and ship a silent failure.
In Rust, I model every failure mode as an enum variant. The compiler forces me to handle each one:
```rust title="src/error.rs"
pub enum ApiError {
RateLimited, // wait and retry with backoff
NotFound, // this trader/market is gone, skip forever
ResourceGone(String), // market deleted, skip
InvalidResponse(String), // returned garbage, don't retry
Timeout(u64), // transient, retry
ExchangeUnavailable { status: u16, body: String },
InvalidRequest(String),
UpstreamAuth(String),
}
```
`RateLimited` goes back in the queue with exponential backoff. `NotFound` gets skipped forever. `InvalidResponse` gets logged and dropped — no point retrying garbage. Each variant carries exactly the data needed to make the right recovery decision.
The real payoff comes later. When I add a new variant — say, a new exchange introduces a rate limit with a retry-after header — the compiler flags every `match` statement in the codebase that doesn't handle it. Every single one. Not "you should probably check your error handling." A hard compile error in every file that matches on this enum. No silent failures. No empty `catch {}`. No "we swallowed an error somewhere and now a user's portfolio shows $0."
## The fast lane: priority scheduling with biased select
When a user visits a trader's profile on 0xInsider, the system syncs that trader's latest data on demand. But the sync loop is also running bulk jobs — background re-syncs, discovery, periodic refreshes. Without careful scheduling, a user's profile visit could wait behind ten bulk jobs. The page would feel slow for no good reason.
Rust's async runtime gives me something most languages can't: priority scheduling with `tokio::select!` and its `biased` mode.
```rust title="src/sync/sync_loop.rs"
loop {
tokio::select! {
biased;
Some(job) = fast_lane_rx.recv() => {
// Profile visits get handled immediately
spawn_sync(job, &fast_semaphore).await;
}
_ = redis_interval.tick() => {
// Bulk jobs only run when the fast lane is empty
if let Some(job) = queue.dequeue().await {
spawn_sync(job, &bulk_semaphore).await;
}
}
}
}
```
The `biased` keyword tells Tokio to check the fast lane channel *first*, every iteration. A profile visit never waits behind a bulk sync — it jumps the queue. Two separate semaphores (3 fast lane slots, 7 bulk slots) ensure that a burst of bulk work can't starve interactive requests, and vice versa.
In Go, `select` is deliberately fair — it picks a random ready case. You can't prioritize without building your own scheduling layer. In Node.js, the event loop processes callbacks in insertion order. Rust's `biased` select is a single keyword that gives you deterministic priority scheduling for free. It directly affects UX: trader profiles load fast because the sync never waits in line.
## Backpressure that can't deadlock
0xInsider detects large trades in real time via a persistent WebSocket connection to the exchange. Every trade that settles on-chain flows through this socket. The reader parses each message, filters for trades above the dollar threshold, and pushes them downstream for database insertion and enrichment — order book snapshots, signal scoring, trader auto-import, SSE broadcast to the frontend. Users see a large trade in the terminal within a second of it settling.
That's the happy path. The failure mode is what matters: if the database slows down during a bulk sync or a materialized view refresh, the insertion step backs up. Without backpressure, the WebSocket reader blocks waiting for the flusher, misses a heartbeat, disconnects, and triggers a reconnect storm — losing trades during every reconnect window.
I use a bounded `mpsc` channel between the reader and the flusher:
```rust title="src/polymarket_ws/stream.rs"
let (flush_tx, flush_rx) = tokio::sync::mpsc::channel::>(8);
```
The channel holds 8 batches maximum. If the database is slow and the channel fills up, `try_send()` returns `Err` — the reader drops that batch and keeps reading the socket. The WebSocket connection stays alive. No missed heartbeat, no reconnect, no trade gap.
Any trades dropped during a slow flush get picked up by a REST poller that runs every 60 seconds as a backfill. The two systems write to the same table with the same unique constraint — `ON CONFLICT DO NOTHING` — so duplicates are impossible. Both sort their inserts by `(condition_id, transaction_hash)` to prevent deadlocks when they write concurrently.
In most languages, coordinating a real-time reader, a batched flusher, and a backfill poller — all writing to the same table without deadlocks or data loss — requires careful manual synchronization. In Rust, the bounded channel enforces backpressure, the compiler ensures the reader and flusher can't share mutable state, and the type system prevents you from accidentally using a blocking send where you need a non-blocking one.
## Deterministic resource cleanup
0xInsider streams trades to browsers via server-sent events. Hundreds of concurrent connections, each holding a slot. When a user closes their tab, disconnects, or times out, that slot needs to be released — immediately, not "eventually when the GC runs."
In Rust, I wrote a guard struct with a `Drop` implementation:
```rust title="src/sse.rs"
struct SseConnectionGuard {
user_id: i32,
connections: Arc>,
}
impl Drop for SseConnectionGuard {
fn drop(&mut self) {
if let Some(count) = self.connections.get(&self.user_id) {
count.fetch_sub(1, Ordering::Relaxed);
}
GLOBAL_SSE_COUNT.fetch_sub(1, Ordering::Relaxed);
}
}
```
The guard moves into the async stream. When the stream ends — for any reason — Rust drops the guard and the counter decrements. Client disconnects? Dropped. Server timeout? Dropped. Panic somewhere upstream? Still dropped. You can't forget to clean up because cleanup isn't your job. The language handles it.
This pattern repeats everywhere in the system. Semaphore permits release when the sync task finishes — even if it panics. Database connections return to the pool when the query handler exits scope. Redis connections are reclaimed automatically. In a system with seven concurrent subsystems sharing resources, deterministic cleanup isn't a nice-to-have. It's the reason the connection pool isn't exhausted after 48 hours and the SSE slot counter doesn't drift.
## Two pools, zero cross-contamination
The backend runs two separate database connection pools — one for the API (user requests) and one for the sync pipeline (background data processing). They have different configurations because they serve different workloads:
```rust title="src/main.rs"
// API pool: fast queries, short timeouts
let api_pool = PgPoolOptions::new()
.max_connections(api_pool_size) // default 8
.acquire_timeout(Duration::from_secs(5))
.after_connect(|conn, _| Box::pin(async move {
conn.execute("SET statement_timeout = '30s'").await?;
conn.execute("SET work_mem = '4MB'").await?;
Ok(())
}))
.connect(&database_url).await?;
// Sync pool: heavy queries, generous timeouts
let sync_pool = PgPoolOptions::new()
.max_connections(sync_pool_size) // default 10
.acquire_timeout(Duration::from_secs(30))
.after_connect(|conn, _| Box::pin(async move {
conn.execute("SET statement_timeout = '600s'").await?;
conn.execute("SET work_mem = '8MB'").await?;
Ok(())
}))
.connect(&database_url).await?;
```
The API pool has a 30-second statement timeout. If a query takes longer, it gets killed — something is wrong, and the user shouldn't wait. The sync pool gets 600 seconds because a full trader sync fetches thousands of records, computes P&L, and reconciles market outcomes. That's legitimately slow work.
The important thing: the compiler enforces which functions use which pool. API handlers receive `&ApiState` which holds the API pool. Sync tasks receive `&SyncState` which holds the sync pool. You can't accidentally run a 10-minute sync query on the API pool and block every user request. The type system makes the mistake impossible.
## Three lines that cut memory 30%
Rust lets you swap the global memory allocator. The default system allocator fragments heavily under concurrent workloads — ten sync workers, hundreds of rate limiter entries, cache objects being created and destroyed constantly. Jemalloc is designed for exactly this pattern:
```rust title="src/main.rs"
#[cfg(not(target_env = "msvc"))]
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
```
Three lines. No code changes anywhere else. The allocator handles every allocation in the entire process — Tokio's task scheduler, DashMap entries, database connection buffers, JSON parsing. Memory usage dropped roughly 30% under production load.
You can't do this in Go, Node.js, or Python. The runtime owns the allocator. In Rust, it's a compile-time decision with zero runtime overhead. For a single-process system that runs everything — API server, sync workers, WebSocket handlers, job queue — memory efficiency compounds.
## One binary, one deploy
The whole system compiles to a single binary. No runtime dependencies, no version mismatches between services, no "works locally but the container is missing a library." The Dockerfile is a multi-stage build — compile the Rust code, copy one binary into a minimal Debian image. The final container is about 150MB: the entire API server, all sync workers, WebSocket handlers, and the job queue processor.
For a solo developer, this matters more than it sounds. One binary means one thing to deploy, one thing to monitor, one process to restart, one set of logs to search. No Docker Compose orchestrating five services. No service mesh. No "the worker is up but the API isn't" at 2am. When the deploy goes out, everything goes out together, and either all of it works or none of it does.
## The tradeoffs
Rust is not all upside. I've been writing it for five months. The borrow checker still fights me regularly — there are moments where I know what I want to do and spend 20 minutes convincing the compiler I'm not about to cause a use-after-free. Compile times are painful. A clean build takes minutes, not seconds. And the learning curve is genuinely steep. Coming from years of TypeScript, Rust demanded a different kind of thinking — but that discipline is exactly why the system is as solid as it is.
The roadmap makes the tradeoff clearer. Right now, 0xInsider reads and displays data. What's coming is direct trade execution from the terminal, copy trading where you mirror a top trader's positions automatically, and automated strategies. Placing a real trade on behalf of a user, with real money, is a much higher safety bar than showing them a chart. Copy trading means the system makes decisions autonomously — buying and selling based on another trader's activity. A race condition there doesn't show a stale number. It loses someone's money. I'd rather already be in a language built for that than rewrite the backend when the stakes get higher.
The trade isn't writing speed. It's the bugs that never happen. The production incidents that never fire. The race conditions that can't compile. The SQL schema drift caught at build time instead of at 3am. For a financial product with a team of one, I'd pick Rust again without hesitating.
---
--- title: Prediction Markets Broke My Brain About Probability description: A year of watching people bet real money on outcomes changed how I think about everything. topics: ['reflections', 'tech'] ---
# Prediction Markets Broke My Brain About Probability
Before last year, when someone told me "I'm pretty sure this will work," I'd take it at face value. Now I don't. Not because I'm cynical — because I spent a year watching thousands of people put real money behind "pretty sure" and most of them were wrong.
For the past year I've been tracking prediction market traders — grading them on ~40 metrics like Sharpe ratio, drawdown, and profit factor. The project is called [0xinsider](https://0xinsider.com). Prediction markets let you bet on real-world outcomes: elections, earnings, sports, crypto. If you think something has a 70% chance of happening and the market says 50%, you buy. If you're right more often than you're wrong, you make money. Simple in theory.
In practice, most people lose. And the way they lose taught me more about confidence and decision-making than anything I've read.
Here's a sample of 47,342 traders, graded S through F:
Almost half land at D. Another 28% sit at C. S and A combined are under 10%. The distribution looks like most grading curves — except these grades are backed by real money, not assignments.
Here's what each grade actually means in dollars. The left column is median P&L — the midpoint trader in that grade. The right bar shows what percentage of traders in that grade have lost money overall (negative lifetime profit):
The median S-grade trader has made $1.17M, and literally zero of them are in the red. The median D-grade trader — the biggest group — is down $23. That sounds harmless until you see that 78.6% of them have negative lifetime profit. They're losing, just slowly. F-grade is worse: median loss of $42K, and 97.3% of them have lost money.
Here's what surprised me. F-grade traders aren't small. Their median trading volume is $2.2M. Seventy percent of them have pushed over $1M through prediction markets. These are people betting millions and still losing. It's not a bankroll problem. It's a judgment problem.
S-grade traders trade an average of 6,736 markets. F-grade traders trade 823. The best traders don't just bet better — they bet more often, across more markets, with smaller individual positions. They're diversified. The worst traders concentrate into fewer, bigger bets and get crushed.
The overall median P&L across all traders is −$0.90. Basically zero. A few S-grade outliers pull the average up to +$32K, but that number is meaningless for the typical trader. Most people are either treading water or slowly losing.
The data doesn't show that people are bad at predicting things. It shows that people are bad at knowing how good they are at predicting things. The F-grade traders aren't making small, cautious bets. They're going big. They have conviction. They're just wrong.
The traders who actually win don't look confident. They look boring. Lots of small positions across thousands of markets. No dramatic all-in bets. When they're wrong — and they are, often — it doesn't matter because no single position can hurt them.
I catch myself thinking this way about everything now. Someone says "this project will take two weeks" and I wonder — is that a high-conviction bet on a few data points, or a calibrated estimate from deep experience? The prediction market data didn't teach me to think in probabilities. It taught me that confidence is almost completely uncorrelated with accuracy, and that the people who do the best are the ones who already know that.
---
--- title: How I Use Vercel BotID to Stop Bots on Auth Endpoints description: BotID verifies browser challenges before proxying to the backend. Here's how I set it up in Next.js 16. topics: ['webdev'] ---
# How I Use Vercel BotID to Stop Bots on Auth Endpoints
Bots hit auth endpoints constantly. Login forms, magic links, signup codes — anything that talks to a database or sends an email is a target. Rate limiting helps, but sophisticated bots rotate IPs and fingerprints. You need something that runs a challenge in the browser before the request ever reaches your server.
Vercel BotID does this. It runs a client-side challenge on routes you specify, attaches proof-of-humanity headers to the request, and gives you a server-side check that classifies the session as human or bot. If it's a bot, you reject the request before doing any expensive work.
I added it to [0xInsider](https://0xinsider.com) to protect the auth flow — magic link sends, token verification, signup codes. Here's how it works.
## The architecture
My setup is a Next.js 16 frontend on Vercel that proxies API calls to a Rust backend on Railway. The proxy lives in `proxy.ts` middleware — it rewrites `/api/auth/*` requests to the backend with an API key header.
The problem: BotID's `checkBotId()` function only works inside Next.js server context (route handlers or server actions). It can't run in middleware. So I can't just drop it into the existing proxy.
The solution: thin Next.js route handlers that sit between the client and the backend proxy.
```
Browser
│
│ fetch("/api/botid/magic-link")
│ (BotID challenge headers attached automatically)
│
▼
Next.js route handler
│
│ checkBotId() → is this a bot?
│
├── YES → 403 Blocked
│
└── NO → proxy to Railway backend
POST /api/auth/magic-link
```
The client-side BotID script intercepts fetch requests to protected routes and attaches challenge headers. The server-side `checkBotId()` reads those headers and classifies the session. If it passes, the route handler manually proxies to the backend — same as what `proxy.ts` does, but with the bot check first.
Install the package and wrap your Next.js config:
```bash
npm i botid
```
```typescript title="next.config.ts"
const nextConfig: NextConfig = {
// your existing config
};
export default withBotId(nextConfig);
```
`withBotId` adds proxy rewrites that serve BotID's challenge script from your own domain. This matters because ad-blockers can't fingerprint it as a third-party bot-detection script.
Next, register the routes you want to protect. Next.js 16 supports `instrumentation-client.ts` for client-side initialization:
```typescript title="instrumentation-client.ts"
initBotId({
protect: [
{ path: "/api/botid/magic-link", method: "POST" },
{ path: "/api/botid/verify", method: "POST" },
{ path: "/api/botid/validate-code", method: "POST" },
{ path: "/api/botid/redeem-code", method: "POST" },
],
});
```
When the browser makes a `POST` to any of these paths, BotID's client script intercepts it, solves a challenge, and attaches the result as headers. This happens transparently — no UI, no CAPTCHA.
On the server side, I wrote a shared helper that every route handler uses:
```typescript title="app/api/botid/_proxy.ts"
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
const API_KEY = process.env.API_SECRET_KEY ?? "";
export async function verifyAndProxy(
request: NextRequest,
backendPath: string,
): Promise {
const verification = await checkBotId();
if (verification.isBot && !verification.isVerifiedBot) {
return NextResponse.json({ error: "Blocked" }, { status: 403 });
}
const body = await request.text();
const headers: Record = {
"Content-Type": "application/json",
};
if (API_KEY) headers["x-api-key"] = API_KEY;
const clientIp = request.headers
.get("x-forwarded-for")
?.split(",")[0]
?.trim();
if (clientIp) headers["x-real-ip"] = clientIp;
const res = await fetch(`${API_URL}${backendPath}`, {
method: "POST",
headers,
body,
});
const data = await res.text();
return new NextResponse(data, {
status: res.status,
headers: { "Content-Type": "application/json" },
});
}
```
`checkBotId()` reads the challenge headers from the incoming request automatically — no arguments needed in Next.js. If the session is a bot and not a verified bot, we return 403 immediately. If it's human or a verified bot (like ChatGPT Operator or Perplexity — see the full directory at [bots.fyi](https://bots.fyi)), we forward everything to the backend exactly like the proxy middleware would.
Each route handler is four lines:
```typescript title="app/api/botid/magic-link/route.ts"
export async function POST(request: NextRequest) {
return verifyAndProxy(request, "/api/auth/magic-link");
}
```
Same pattern for `/verify`, `/validate-code`, and `/redeem-code`. Each one maps to a backend auth endpoint.
The last piece: tell your proxy middleware to leave these routes alone. If you have a middleware that rewrites `/api/*` routes to your backend (like I do), you need to exclude `/api/botid/*` so the route handlers actually run:
```typescript title="proxy.ts"
const ROUTE_HANDLER_ROUTES: string[] = ["/api/botid"];
```
Then update your frontend auth calls to hit the new paths:
```typescript title="lib/auth.ts"
// Before
authFetch("/api/auth/magic-link", { method: "POST", body });
// After
authFetch("/api/botid/magic-link", { method: "POST", body });
```
## What happens locally
BotID always returns `isBot: false` in development. Your auth flow works exactly the same — the bot check just passes through. If you want to test the blocking behavior locally, pass `developmentOptions`:
```typescript
const verification = await checkBotId({
developmentOptions: { bypass: "BAD-BOT" },
});
```
In production, BotID has two tiers. Basic is free and uses client/network signals. Deep Analysis costs $1 per 1,000 checks and does asynchronous investigation of suspicious sessions. You enable Deep Analysis in the Vercel dashboard under Firewall → Rules — it's a toggle, not a code change.
For auth endpoints that send emails or hit a database, $1 per 1,000 checks is worth it. A single bot that signs up 10,000 fake accounts costs way more in email sends and database bloat.
## Handling verified bots
Not all bots are bad. ChatGPT Operator, Perplexity, and other AI assistants are verified bots that you probably want to let through. BotID (v1.5.0+) tells you when a bot is verified via `isVerifiedBot`, `verifiedBotName`, and `verifiedBotCategory`.
That's why the check above uses `verification.isBot && !verification.isVerifiedBot` — it blocks scrapers and credential stuffers while letting verified bots interact with your app normally. Vercel maintains a directory of verified bots at [bots.fyi](https://bots.fyi).
If you need finer control, you can check `verifiedBotName` directly:
```typescript
const { isBot, isVerifiedBot, verifiedBotName } = await checkBotId();
// Only allow specific verified bots
const allowedBots = ["chatgpt-operator", "perplexitybot"];
const isAllowed = isVerifiedBot && allowedBots.includes(verifiedBotName ?? "");
if (isBot && !isAllowed) {
return NextResponse.json({ error: "Blocked" }, { status: 403 });
}
```
---
--- title: Google Search Console MCP for Claude Code description: Pull GSC data into Claude Code to find CTR problems, indexing bugs, and keyword gaps — then fix them in the same conversation. topics: ['llm', 'webdev'] ---
# Google Search Console MCP for Claude Code
[mcp-gsc](https://github.com/AminForou/mcp-gsc) is a Python MCP server that connects Google Search Console to Claude Code. Once connected, Claude can pull search analytics, inspect URLs, check indexing status, and manage sitemaps — all through tool calls.
I set it up for [0xinsider.com](https://0xinsider.com) and ran an audit. The first thing it found was that my `robots.ts` file had `disallow: ["/"]`, which tells crawlers to avoid the entire site. That had been live for weeks.
Without this, here's how that would've gone: open Search Console, notice low impressions, scratch my head, check a few pages manually, eventually think to look at `robots.txt`, open VS Code, find the file, spot the conflict, fix it. Thirty minutes if I'm lucky, days if I don't think to check robots.
With the MCP server, Claude pulled the performance data and the robots config in the same turn. It saw low impressions next to a misconfigured crawl rule and connected the dots. I typed "fix it" and the file was updated. The whole thing took seconds.
That's the real value — not just reading data faster, but closing the loop between diagnosis and fix. Every SEO workflow I've done before involved jumping between the Search Console dashboard, a spreadsheet, and my editor. Now it's one conversation.
### Setup
The server uses OAuth to authenticate with your Google account. You need a Google Cloud project with the Search Console API enabled.
1. Go to [Google Cloud Console](https://console.cloud.google.com/), create a project (or use an existing one)
2. [Enable the Search Console API](https://console.cloud.google.com/apis/library/searchconsole.googleapis.com)
3. Go to [Credentials](https://console.cloud.google.com/apis/credentials), create an OAuth 2.0 client ID (Desktop app type)
4. Download the JSON file as `client_secrets.json`
Clone the server and install dependencies:
```bash
git clone https://github.com/AminForou/mcp-gsc.git
cd mcp-gsc
uv venv .venv && source .venv/bin/activate
uv pip install -r requirements.txt
```
Place your `client_secrets.json` in the `mcp-gsc` directory. On first run, the server opens a browser for OAuth consent. The token gets saved to `token.json`.
Add it to your Claude Code MCP config:
```json
{
"mcpServers": {
"gsc": {
"command": "/path/to/mcp-gsc/.venv/bin/python",
"args": ["/path/to/mcp-gsc/gsc_server.py"]
}
}
}
```
Restart Claude Code. You should see GSC tools available.
### What it found on [0xinsider.com](https://0xinsider.com)
I asked Claude to investigate search performance. It pulled performance overview, top queries, top pages, device breakdown, country data, sitemaps, and URL inspection results — all in parallel, in one turn.
28-day numbers:
| Metric | Value |
|--------|-------|
| Clicks | 59 |
| Impressions | 5,116 |
| Average CTR | 1.15% |
| Average Position | 6.2 |
Here's what it found:
**robots.ts was blocking crawlers.** The file had both `allow: "/"` and `disallow: ["/"]`. When these conflict, the more specific rule wins — so Google was being told not to crawl anything. Here's what it looked like:
```typescript
// Before (broken)
rules: {
userAgent: "*",
allow: "/",
disallow: ["/"],
}
// After (fixed)
rules: {
userAgent: "*",
allow: "/",
disallow: ["/api/"],
}
```
**The learn guide had a search intent mismatch.** The page `/learn/how-to-trade-prediction-markets` had 1,411 impressions and 1 click — a 0.07% CTR. The queries driving those impressions were about "how to invest in prediction markets through stocks," not about trading on Polymarket directly. The title said "How to Trade" but users were searching for "How to Invest." Changed the title to match the actual queries.
**Leaderboard wasn't ranking for its own keyword.** The `/leaderboard` page was showing up at position 12-48 for "polymarket leaderboard" queries. The title was "Top Polymarket Traders — Most Advanced Trader Rankings." It didn't contain the word "leaderboard." Changed it to "Polymarket Leaderboard — Top Traders Ranked by Profit."
**Trader profiles had generic meta descriptions.** Every profile page had the same template: "Deep dive into @name's Polymarket strategy. Full P&L breakdown..." — no actual stats. Updated `generateMetadata` to fetch trader data and include real numbers: P&L, win rate, markets traded. So now a profile shows something like "@gabagool22 Polymarket Profile — $214K P&L, 52% win rate" in search results instead of a generic blurb.
**Desktop CTR was 5x worse than mobile.** Desktop: 4,321 impressions, 0.74% CTR. Mobile: 767 impressions, 3.52% CTR. The meta descriptions were too long and generic for desktop SERPs where Google shows more text. Shortened them and added specific numbers.
All seven fixes happened in the same Claude Code session where the data was pulled. No tab switching, no exporting CSVs, no copying query data into a spreadsheet.
### The tools
The server exposes 19 tools. The ones I used most:
| Tool | What it does |
|------|-------------|
| `get_performance_overview` | Clicks, impressions, CTR, position by day |
| `get_search_analytics` | Top queries or pages, grouped by dimension |
| `get_advanced_search_analytics` | Same but with filters, sorting, pagination |
| `batch_url_inspection` | Check indexing and rich results for multiple URLs at once |
| `get_search_by_page_query` | See which queries drive traffic to a specific page |
| `compare_search_periods` | Diff two time ranges |
`batch_url_inspection` is the one that saves the most time. In the Search Console UI, you inspect URLs one at a time. With the MCP tool, you pass 5 or 10 URLs and get back indexing status and rich result detection for all of them in one call.
`get_search_by_page_query` is useful for diagnosing CTR problems. You see a page with 30,000 impressions and 0.11% CTR, so you pull the queries for that page and discover the title doesn't match what people are actually searching for.
### What makes this different from just using the GSC dashboard
The dashboard shows data. Claude Code shows data and has access to the codebase at the same time.
When Claude finds that a page has high impressions and low CTR, it can read the page's frontmatter, see the current title and meta description, compare them against the actual search queries, and propose a change — all without you navigating anywhere. The gap between finding a problem and fixing it is one sentence: "fix it."
The `robots.txt` bug is a good example. The performance data alone didn't explain why impressions were low. The robots data alone looked like a normal config file. It was the combination — seeing poor performance alongside a misconfigured robots file — that surfaced the real issue.
---
--- title: I Built 0xInsider.com — a Whale Trade Tracker for Prediction Markets description: Every large trade on Polymarket and Kalshi, the second it happens. Currently in beta — join the Discord for early access. topics: ['prediction markets', 'tech'] ---
# I Built 0xInsider.com — a Whale Trade Tracker for Prediction Markets
Prediction markets let you trade on the outcome of real events — elections, economic data, crypto prices, whether a CEO resigns. You buy contracts that pay $1 if you're right, $0 if you're wrong. Polymarket and Kalshi are the two biggest.
```
"Will the Fed cut rates in March 2026?"
┌─────────────────────────────────────┐
│ YES $0.62 ← 62% chance │
│ NO $0.38 ← 38% chance │
│ ─────── │
│ $1.00 │
│ │
│ Buy YES at 62¢ → pays $1 if right │
│ Buy NO at 38¢ → pays $1 if right │
└─────────────────────────────────────┘
```
Simple enough. But neither Polymarket nor Kalshi shows you what other traders are doing. Large trades happen constantly on both platforms, but unless you're watching the right market at the right moment, you miss them. You see the price change after the fact and have no idea what caused it.
I kept running into this. So I built [0xInsider](https://0xinsider.com).
It's a real-time terminal that shows every large trade on Polymarket and Kalshi as it happens. Both platforms, one feed.
```
TIME SIDE TRADER MARKET SIZE PRICE
────────────────────────────────────────────────────────────────────────────────────
P 3s ago BUY CryptoWhale... Will BTC hit $120k by Apr? $23,400 42¢
K 8s ago BUY Anonymous Fed rate hold in March? $31,500 84¢
P 12s ago SELL degen_0x42... Next Supreme Court retirement? $18,100 61¢
K 15s ago BUY Anonymous Unemployment rate Jan 2026? $34,485 95¢
P 19s ago BUY polywhale... GDP growth above 3%? $27,000 38¢
```
Trades stream in via server-sent events. No polling, no refreshing.
## What you actually see
The two platforms give you very different levels of detail.
Polymarket is on-chain. Every trade is tied to a wallet address, so I can show the trader's history — win rate, total P&L, number of markets traded, and a performance grade.
```
Polymarket trade:
┌──────────────────────────────────────────┐
│ BUY $23,400 — Will BTC hit $120k? │
│ │
│ Trader: CryptoWhale... Grade: A │
│ Win rate: 71% Markets: 143 │
│ Total P&L: +$284,500 │
│ Recent: 4/5 wins │
│ │
│ → View full profile │
└──────────────────────────────────────────┘
```
A $23K trade from someone with a 71% win rate across 143 markets reads differently than the same trade from a new wallet with no history. Doesn't mean the first one is right — but it's useful context.
Kalshi is different. It's a centralized, CFTC-regulated exchange. Trades are anonymous — no user IDs, no wallet addresses. You see the size, the price, and the direction, but not who. That's how Kalshi works. Their API doesn't expose trader identities.
```
Kalshi trade:
┌──────────────────────────────────────────┐
│ BUY $31,500 — Fed rate hold in March? │
│ │
│ Trader: Anonymous │
│ Kalshi does not reveal trader identities │
│ │
│ → View market on Kalshi │
└──────────────────────────────────────────┘
```
What matters is you see the trade at all. Before this, Kalshi whale activity was invisible unless you happened to be staring at that exact market.
## What large trades can tell you
Large trades don't predict the future. Someone dropping $30K on an outcome might have great research, or they might be wrong. But they do tell you something: someone with real money has conviction about a specific outcome at a specific price.
Here's a situation I kept noticing:
```
Without a trade feed:
t=0s Someone buys $30K YES at 42¢
│
t=30s Price ticks to 44¢
│
t=5min Price settles around 47¢
│
t=10min You check the market. See 47¢.
No idea what moved it.
With a trade feed:
t=0s Someone buys $30K YES at 42¢
│
t=2s Trade appears in your terminal
│
Now you know what happened.
Whether you act on it is up to you.
```
The information was always there. It just wasn't surfaced anywhere.
Some traders also watch for pricing differences when the same event is listed on both platforms. If YES trades at 58¢ on Kalshi and 52¢ on Polymarket, that gap might mean something — or it might just be different liquidity. But you can't evaluate it if you can't see both sides.
The terminal is at [0xinsider.com](https://0xinsider.com). It's currently in beta — if you want early access, join the [Discord](https://discord.com/invite/uRzs9CwV) and ask. We're also posting trade highlights and updates on [X/Twitter](https://x.com/0xinsiderdotcom).
---
--- title: AI Agents Explained description: What AI agents are, how the agent loop works, and why they're different from chatbots. topics: ['llm'] ---
# AI Agents Explained
You tell ChatGPT to "fix the bug" and it gives you a code snippet. You copy it, paste it, realize it's wrong, go back, paste the error, get another snippet. Repeat.
You tell Claude Code to "fix the bug" and it does everything itself. It opens your files, finds the problem, writes a fix, runs the tests. If a test still fails, it goes back, tries a different approach, and runs the tests again. When everything passes, it commits.
Same model underneath. The difference is agency.
An agent is just a program that can use tools, look at what happened, and decide what to do next — in a loop. A chatbot gives you one response and stops. An agent keeps going until the job is done.
Here's the difference visually. A chatbot is one turn — you ask, it answers, you're on your own.
```
Chatbot
You: "Fix the bug in auth.ts"
│
▼
┌─────────┐
│ Model │
└────┬─────┘
│
▼
"Here's the fix:
Change line 42..."
Done. You go copy-paste it.
```
An agent loops. It thinks, does something, checks if it worked, and keeps going.
```
Agent
You: "Fix the bug in auth.ts"
│
▼
┌─────────┐
│ Think │ ◄──────────────┐
└────┬─────┘ │
│ │
▼ │
┌─────────┐ │
│ Act │ call a tool │
└────┬─────┘ │
│ │
▼ │
┌─────────┐ not done │
│ Observe │ ───────────────┘
└────┬─────┘
│ done
▼
"Fixed. Tests pass.
Committed to main."
```
One gives you instructions. The other does the work.
Every agent you've heard of — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://openai.com/index/introducing-codex/), [Cursor](https://www.cursor.com/), [Devin](https://devin.ai/) — runs this same loop. Think, act, observe, repeat. That's it. People call it the ReAct pattern (Reason + Act), but it's really just a while loop.
Say you ask an agent: "Find all TypeScript files with unused imports and remove them." Here's what actually happens under the hood:
```
Think
"I need to find .ts files first."
│
▼
Act
glob("src/**/*.ts")
│
▼
Observe
Found 47 files.
│
▼
Think
"Now I'll check each file for unused imports.
Let me start with src/utils/auth.ts."
│
▼
Act
read("src/utils/auth.ts")
│
▼
Observe
Line 2: ← never used
│
▼
Act
edit("src/utils/auth.ts", remove line 2)
│
▼
Think
"Removed. Let me verify it still compiles."
│
▼
Act
run("npx tsc --noEmit")
│
▼
Observe
No errors. Move to next file.
│
▼
... repeat for 46 more files ...
│
▼
Think
"All files checked. 12 unused imports removed.
Everything compiles. Done."
```
Notice it's not running all these steps in advance. It decides each step based on what it saw in the previous one. That's the key difference between an agent and a script. A script follows a fixed path. An agent adapts.
Now here's the thing — an agent is only as useful as its tools. Without tools, it's just a chatbot with extra steps. The model itself doesn't actually touch your filesystem. It outputs a structured tool call — basically saying "hey, I want to read this file" — and the system around it does the actual work and passes the result back.
```
┌──────────────────────────────────┐
│ AI MODEL │
│ │
│ "I need to read a file" │
│ │ │
│ ▼ │
│ tool_call: read_file │
│ args: { path: "src/auth.ts" } │
└──────────┬───────────────────────┘
│
▼
┌──────────────────────────────────┐
│ TOOL EXECUTOR │
│ │
│ read_file("src/auth.ts") │
│ │ │
│ ▼ │
│ returns file contents │
└──────────┬───────────────────────┘
│
▼
┌──────────────────────────────────┐
│ AI MODEL │
│ │
│ "Now I can see the bug on │
│ line 42. Let me fix it." │
└──────────────────────────────────┘
```
Common tools agents use:
| Tool | What it does |
|------|-------------|
| `read_file` | Read a file from disk |
| `write_file` | Write or edit a file |
| `run_command` | Execute a shell command |
| `web_search` | Search the internet |
| `list_files` | List directory contents |
Different agents give the model different amounts of freedom. Some ask permission before every action. Some run fully autonomously and just show you the result. The loop is the same — the leash is what changes.
You can build one yourself. It's simpler than you'd think. Here's a working agent in TypeScript — it takes a goal, calls Claude, and loops until the model says it's done.
```typescript title="agent.ts"
const client = new Anthropic();
const tools: Anthropic.Tool[] = [
{
name: "run_command",
description: "Run a shell command and return the output",
input_schema: {
type: "object" as const,
properties: {
command: { type: "string", description: "The shell command to run" },
},
required: ["command"],
},
},
];
async function agent(goal: string) {
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: goal },
];
while (true) {
const response = await client.messages.create({
model: "claude-sonnet-4-5-20250929",
max_tokens: 1024,
tools,
messages,
});
// If the model responds with text and no tool calls, it's done
if (response.stop_reason === "end_turn") {
const text = response.content.find((b) => b.type === "text");
return text?.text;
}
// Otherwise, execute each tool call
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type === "tool_use") {
const input = block.input as { command: string };
const result = execSync(input.command, { encoding: "utf-8" });
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: result,
});
}
}
// Feed results back into the conversation
messages.push({ role: "assistant", content: response.content });
messages.push({ role: "user", content: toolResults });
}
}
agent("What's the latest version of @anthropic-ai/sdk on npm? Check using curl.");
```
The whole thing is a `while(true)` loop. Call the model, check if it wants to use a tool, execute the tool, feed the result back. That's an agent.
I ran this on my machine. `npx tsx agent.ts` and watched it work:
```bash title="npx tsx agent.ts"
🎯 Goal: What's the latest version of @anthropic-ai/sdk on npm? Check using curl.
Step 1: Think
"I'll check the latest version of @anthropic-ai/sdk on npm using curl."
Step 2: Act
$ curl -s https://registry.npmjs.org/@anthropic-ai/sdk/latest | grep -o '"version":"[^"]*"' | head -1
Step 3: Observe
"version":"0.72.0"
✅ Done: The latest version of @anthropic-ai/sdk on npm is 0.72.0.
```
```
agent("Count .ts files")
│
▼
┌─────────────┐
│ Call model │ ◄──────────────────┐
└──────┬──────┘ │
│ │
▼ │
┌──────────────┐ tool_use? │
│Check response│───── yes ──► Execute tool
└──────┬───────┘ │
│ no │
│ (end_turn) Feed result back
▼
Return final answer
```
In production you'd add error handling, timeouts, and permission checks. But the core is always this loop.
Agents aren't magic though. They fail in ways you'll recognize pretty quickly.
They loop forever. The agent edits a file, runs the test, it fails, edits it back, runs the test again. Over and over. Good agents have a retry limit.
They pick the wrong tool confidently. The model decides to delete a file when it should have edited it. This is why every serious agent has a permission system.
They forget what they were doing. Long tasks generate tons of tool results. Eventually the conversation gets so long the model loses track of the original goal. The best agents summarize as they go to keep context manageable.
They compound small mistakes. Step 3 is slightly off. Step 7 builds on it. By step 15, the agent has built an elaborate wrong solution and feels great about it. Shorter feedback loops help — verify after each step, not after 50.
Agents work best when the task is clear and there's a way to check the result. "Fix the failing test" is perfect — the agent can run the test to verify. "Make the UI look better" is terrible — it can't see what the UI looks like.
---
--- title: How Prediction Market Arbitrage Works (Polymarket, Kalshi) description: Buy YES and NO for less than a dollar. One of them pays out a dollar. Keep the difference. topics: ['prediction markets'] ---
# How Prediction Market Arbitrage Works (Polymarket, Kalshi)
Polymarket and Kalshi are prediction markets. You trade on the outcome of real events — elections, economic data, whether it'll snow in April. Every question has two contracts: YES and NO.
Each contract pays out $1 if it's right, $0 if it's wrong. You buy at whatever price the market sets.
Here's where it gets interesting. In a perfectly efficient market, YES + NO always equals $1.00. But markets aren't always efficient.
Take a question like "Will Bitcoin hit $100k by March?"
| Contract | Price |
|----------|-------|
| YES | $0.42 |
| NO | $0.55 |
| **Total**| **$0.97** |
YES + NO = $0.97. That's less than a dollar.
You buy both. One YES contract for $0.42 and one NO contract for $0.55. Total cost: $0.97.
Now think about what happens:
- Bitcoin hits $100k → YES pays $1.00, NO pays $0. You get $1.00.
- Bitcoin doesn't hit $100k → YES pays $0, NO pays $1.00. You get $1.00.
Either way, you get $1.00 back. You paid $0.97. That's $0.03 profit per pair, no matter what happens.
```
YOU
│
pay $0.97
┌────┴────┐
▼ ▼
YES NO
$0.42 $0.55
│ │
▼ ▼
┌─────────────────┐
│ ONE of these │
│ pays out $1.00 │
└────────┬────────┘
▼
You get $1.00
Profit: $0.03
```
This is arbitrage. You're not predicting anything. You're exploiting a pricing gap.
## Why the Gap Exists
In theory, YES + NO should always equal $1.00. In practice, they drift apart:
```
Efficient market After news breaks
YES $0.50 YES $0.58 ← buyers rush in
NO $0.50 NO $0.39 ← hasn't adjusted
────────── ──────────
Sum $1.00 Sum $0.97 ← gap opens
```
**New information hits.** A news story breaks and traders rush to buy YES. The YES price jumps, but NO hasn't adjusted yet. For a brief moment, the sum drops below $1.00.
**Low liquidity.** If barely anyone is trading a market, prices get stale. One side updates while the other lags behind.
**Different platforms.** Polymarket might price YES at $0.45 while Kalshi prices NO on the same event at $0.52. Same event, different markets, different prices.
```
Same event: "Will X happen?"
Polymarket Kalshi
┌──────────┐ ┌──────────┐
│ YES $0.45│ │ YES $0.51│
│ NO $0.58│ │ NO $0.52│
└──────────┘ └──────────┘
Buy YES here ───────────────────► Buy NO here
$0.45 + $0.52
──────────
Total: $0.97
Payout: $1.00
Profit: $0.03
```
Three cents doesn't sound like much. But it scales linearly.
| Pairs | Cost | Payout | Profit |
|-------|------|--------|--------|
| 1 | $0.97 | $1.00 | $0.03 |
| 100 | $97 | $100 | $3 |
| 1,000 | $970 | $1,000 | $30 |
| 10,000| $9,700| $10,000| $300 |
The return is small per unit, but it's risk-free. The only question is how many pairs you can buy before the price moves.
## The Catches
**Fees.** Polymarket only charges taker fees — if you place a limit order that sits on the book (maker), you pay zero fees. You only pay when you take someone else's order. Kalshi charges a transaction fee on your expected earnings, and some markets also have maker fees on resting orders (common during elections, big sporting events, awards). A $0.03 gap can disappear after fees eat into it, so how you place orders matters. Maker orders on Polymarket keep the full spread.
**Slippage.** The price you see isn't always the price you get. If you try to buy 1,000 YES contracts at $0.42, there might only be 200 available at that price. The rest fill at $0.43, $0.44, and now your gap is gone.
```
Order book for YES:
Price Available
$0.42 200 contracts ← you want 1,000
$0.43 300 contracts
$0.44 500 contracts
───
1,000 total
Average price: $0.434, not $0.42
Your gap just shrank from $0.03 to $0.016
```
**Speed.** These windows last seconds, not minutes. By the time you spot the gap and click buy, someone else already took it. This is why most prediction market arbitrage is done by bots — programs that watch prices across markets and execute trades in milliseconds.
**Liquidity.** Thin markets have bigger gaps but you can't buy enough pairs to make meaningful money. Liquid markets have smaller gaps but can absorb larger orders.
The gap has to be large enough to survive fees, slippage, and execution time. In practice, that means most opportunities aren't worth it for humans clicking buttons. But for automated systems watching hundreds of markets simultaneously, the small edges add up.
Prediction markets are becoming a real-time layer on top of everything — politics, crypto, macro. The interesting part isn't the arbitrage itself, it's what the pricing tells you about how information moves through markets. We dig into these kinds of signals at [0xinsider.com](https://0xinsider.com/).
---
--- title: Building Custom MCP Servers with Next.js and mcp-handler description: How to build a MCP server that works with Claude Code, Gemini CLI, Cursor, and more. topics: ['llm'] ---
# Building Custom MCP Servers with Next.js and mcp-handler
MCP (Model Context Protocol) lets LLMs call your code. You define tools, and the model uses them when needed.
Without MCP, you copy-paste everything. API responses, error logs, database schemas—all manually fed into the chat. MCP removes that friction. The LLM calls your tools directly.
```
┌─────────────┐ ┌─────────────┐
│ VS Code │ ──"list tools"──────▶ │ MCP Server │
│ │ ◀─tool definitions─── │ │
└─────────────┘ └─────────────┘
│
▼
┌─────────────┐ ┌─────────────┐
│ Claude │ ──"npm_package zod"─▶ │ MCP Server │
│ │ ◀─{version, weekly..} │ │
└─────────────┘ └─────────────┘
│
▼
"zod is at v4.3.6 with 86M weekly downloads"
```
When you ask something, the model calls your tool instead of guessing. Real data, not stale training data.
MCP is an open standard. Once you build a server, it works with Claude Code, Gemini CLI, OpenAI Codex, Cursor, Windsurf—any client that supports MCP.
## Building the MCP Server
I'll build three tools:
**npm_package** — "What version is zod?" Instead of opening npmjs.com, Claude fetches live data from the npm registry. Version, weekly downloads, repo link.
**github_repo** — "How popular is this library?" Stars, forks, open issues, last commit. Useful when evaluating dependencies.
**check_site** — "Is my site up?" Pings a URL and returns status code and response time. Faster than opening a browser.
[mcp-handler](https://github.com/vercel/mcp-handler) turns a Next.js route into an MCP server. Install it:
```bash
mkdir dev-tools-mcp && cd dev-tools-mcp
npm init -y
npm install next react react-dom mcp-handler @modelcontextprotocol/sdk zod
npm install -D typescript @types/node @types/react
```
Create `app/api/[transport]/route.ts`.
**Tool 1: npm package lookup.** Fetches the latest version, description, weekly downloads, and repository URL from the npm registry.
```typescript title="app/api/[transport]/route.ts"
const handler = createMcpHandler(
(server) => {
server.tool(
"npm_package",
"Get npm package info: version, downloads, repo URL.",
{
name: z.string().describe("Package name, e.g. 'zod'"),
},
async ({ name }) => {
const [pkgRes, downloadsRes] = await Promise.all([
fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`),
fetch(`https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(name)}`),
]);
if (!pkgRes.ok) {
return {
content: [{ type: "text", text: `Package "${name}" not found.` }],
isError: true,
};
}
const pkg = await pkgRes.json();
const downloads = await downloadsRes.json();
return {
content: [{
type: "text",
text: JSON.stringify({
name: pkg.name,
version: pkg.version,
description: pkg.description,
weeklyDownloads: downloads.downloads?.toLocaleString() ?? "unknown",
repository: pkg.repository?.url?.replace("git+", "").replace(".git", "") ?? null,
}, null, 2),
}],
};
}
);
},
{},
{ basePath: "/api", maxDuration: 60, verboseLogs: true }
);
export { handler as GET, handler as POST };
```
`server.tool()` takes four arguments: name, description, schema, handler. The description tells the LLM when to use it.
Add the boilerplate files:
```typescript title="app/layout.tsx"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return {children};
}
```
```typescript title="app/page.tsx"
export default function Home() {
return MCP Server running at /api/mcp
;
}
```
Start it:
```bash
npx next dev -p 3001
```
Test it:
```bash
curl -X POST http://localhost:3001/api/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"npm_package","arguments":{"name":"zod"}},"id":1}'
```
Response (text field parsed for readability):
```json
{
"result": {
"content": [{
"type": "text",
"text": {
"name": "zod",
"version": "4.3.6",
"weeklyDownloads": "86,259,569",
"repository": "https://github.com/colinhacks/zod"
}
}]
}
}
```
Connect VS Code. Create `.vscode/mcp.json`:
```json title=".vscode/mcp.json"
{
"mcpServers": {
"dev-tools": {
"url": "http://localhost:3001/api/mcp"
}
}
}
```
Restart VS Code. Ask Claude "what version is zod?" and it calls your tool.
You don't need to explicitly tell Claude to use these tools. The descriptions you wrote—"Get npm package info: version, downloads, repo URL"—tell Claude when each tool applies. When you ask about package versions, Claude matches your question to the tool description and calls it automatically.
**Tool 2: GitHub repo stats.** When evaluating a library, I want to know: Is it maintained? How many stars? Open issues? This tool fetches that from the GitHub API.
```typescript
server.tool(
"github_repo",
"Get GitHub repo info: stars, forks, issues, last update.",
{
owner: z.string().describe("Repo owner, e.g. 'facebook'"),
repo: z.string().describe("Repo name, e.g. 'react'"),
},
async ({ owner, repo }) => {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: { "Accept": "application/vnd.github.v3+json" },
});
if (!res.ok) {
return {
content: [{ type: "text", text: `Repo "${owner}/${repo}" not found.` }],
isError: true,
};
}
const data = await res.json();
return {
content: [{
type: "text",
text: JSON.stringify({
name: data.full_name,
description: data.description,
stars: data.stargazers_count.toLocaleString(),
forks: data.forks_count.toLocaleString(),
openIssues: data.open_issues_count,
lastPush: data.pushed_at,
}, null, 2),
}],
};
}
);
```
**Tool 3: Site uptime.** "Is production down?" Instead of opening a browser, Claude pings the URL and tells me the status code and latency.
```typescript
server.tool(
"check_site",
"Check if a site is up. Returns status and response time.",
{
url: z.string().url().describe("URL to check"),
},
async ({ url }) => {
const start = Date.now();
try {
const res = await fetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(10000),
});
return {
content: [{
type: "text",
text: JSON.stringify({
url,
status: res.ok ? "up" : "down",
httpStatus: res.status,
latencyMs: Date.now() - start,
}, null, 2),
}],
};
} catch (err) {
return {
content: [{
type: "text",
text: JSON.stringify({
url,
status: "unreachable",
error: (err as Error).message,
}, null, 2),
}],
isError: true,
};
}
}
);
```
Here's what it looks like in Claude Code. I ask "what version is zod?" and it calls my tool:
```bash
⏺ npm_package(name: "zod")
⎿ {
"name": "zod",
"version": "4.3.6",
"description": "TypeScript-first schema validation with static type inference",
"weeklyDownloads": "86,259,569",
"repository": "https://github.com/colinhacks/zod"
}
```
"How many stars does zod have?"
```bash
⏺ github_repo(owner: "colinhacks", repo: "zod")
⎿ {
"name": "colinhacks/zod",
"description": "TypeScript-first schema validation with static type inference",
"stars": "41,648",
"forks": "1,791",
"openIssues": 229,
"lastPush": "2026-01-28T00:48:03Z"
}
```
"Is trevorlasn.com up?"
```bash
⏺ check_site(url: "https://trevorlasn.com")
⎿ {
"url": "https://trevorlasn.com",
"status": "up",
"httpStatus": 200,
"latencyMs": 158,
"server": "cloudflare"
}
```
LLMs have a knowledge cutoff. Ask for the latest Next.js version and you might get an old answer. MCP fixes this—the model calls your tools and gets live data.
I used to open browser tabs for everything. Now I ask Claude. One interface, real-time info, no context switching.
---
--- title: CSS :interest-source and :interest-target Pseudo-Classes description: Style connected UI elements with CSS pseudo-classes that respond to user interest. Interactive examples showing tooltips, forms, and navigation without JavaScript. topics: ['webdev', 'css'] ---
# CSS :interest-source and :interest-target Pseudo-Classes
CSS is getting new pseudo-classes that let you style elements based on user interest. The `:interest-source` and `:interest-target` pseudo-classes work with the `interestfor` HTML attribute to create relationships between elements, enabling interactive UIs without JavaScript.
These pseudo-classes are part of the Open UI Interest Invokers proposal, recently accepted by the CSS Working Group. They handle scenarios where hovering or focusing one element should affect the styling of another element elsewhere in the DOM.
The interest invoker system has two parts: an invoker element with the `interestfor` attribute, and a target element it references. When a user shows interest in the invoker (by hovering or focusing), both elements can be styled:
- `:interest-source` matches the element with the `interestfor` attribute when it's receiving interest
- `:interest-target` matches the element being referenced when its invoker has interest
Both pseudo-classes support functional syntax with `partial` or `total` parameters. Partial interest means the user has focused the element but hasn't activated it (like hovering), while total interest means full activation (like clicking).
Traditional CSS requires elements to be in specific DOM relationships (parent-child, siblings) to affect each other's styles. Interest invokers break that limitation—any element can invoke interest in any other element via IDREF.
The HTML uses the `interestfor` attribute to establish the connection:
```html
Hover me
Tooltip content
```
Then CSS can style both the invoker and target:
```css
/* Style the button when showing interest */
:interest-source {
background-color: lightblue;
}
/* Style the tooltip when its invoker has interest */
:interest-target {
opacity: 1;
visibility: visible;
}
```
One of the most common use cases is creating connected tooltips without JavaScript. When you hover over a button or link, you want to show additional information elsewhere on the page. The interest pseudo-classes make this trivial.
### Navigation Highlighting
Another powerful use case is highlighting navigation items when their corresponding sections are in view or being interacted with. This creates a connected experience where different parts of your interface respond to user attention.
### Form Field Relationships
Forms often have helper text, validation messages, or related inputs that should respond to focus on specific fields. Interest pseudo-classes make these relationships explicit and maintainable.
### Browser Support and Fallbacks
These pseudo-classes were accepted by the CSS Working Group in July 2025. Chrome 139+ has experimental support behind a flag. The proposal is part of the Open UI initiative, not CSS Selectors Level 5. For production use, you'll need JavaScript fallbacks or progressive enhancement until browser support matures.
You can detect support using CSS `@supports`:
```css
@supports selector(:interest-source) {
/* Your interest pseudo-class styles */
}
```
The examples on this page use JavaScript to simulate the behavior until browsers ship support.
The CSS Working Group accepted the `:interest-source` and `:interest-target` proposal in July 2025, marking a significant step toward declarative interactive UI in CSS. Chrome 139+ already has experimental support, and the Open UI initiative is actively working to standardize these patterns across all browsers.
The proposal also includes plans for additional functionality, like the `interest-delay-start` and `interest-delay-end` CSS properties to control timing, and a potential `possible` parameter (currently deferred) for handling focusability edge cases.
---
--- title: ::details-content: style expandable content without wrapper divs description: The ::details-content pseudo-element lets you style the expandable content of details elements separately from the summary, no divs needed. topics: ['webdev', 'css'] ---
# ::details-content: style expandable content without wrapper divs
The `::details-content` pseudo-element solves a problem that existed since [``](/blog/html-details-element) was introduced: you couldn't style the content area without wrapping it in a div.
The problem: you want the summary and content to look different, so you'd need to add a wrapper div just for styling:
```html
How do I return an order?
Visit your order page and click "Return Item". You'll receive a prepaid
shipping label via email within 24 hours.
```
```css
details::summary {
padding: 1rem;
background: white;
}
.content-wrapper {
padding: 1rem;
background: #f9fafb;
border-top: 1px solid #e5e7eb;
}
```
Now with `::details-content`, forget the wrapper:
```html
How do I return an order?
Visit your order page and click "Return Item". You'll receive a prepaid
shipping label via email within 24 hours.
```
```css
details::summary {
padding: 1rem;
background: white;
}
details::details-content {
padding: 1rem;
background: #f9fafb;
border-top: 1px solid #e5e7eb;
}
```
Here's what that looks like in action:
You can also animate it:
```css
details::details-content {
opacity: 0;
transition:
opacity 300ms,
content-visibility 300ms allow-discrete;
}
details[open]::details-content {
opacity: 1;
}
```
This fades the content in/out smoothly when opening and closing. The `allow-discrete` keyword lets `content-visibility` participate in the transition—important for performance when you have lots of content inside.
`::details-content` hit Baseline status in September 2025, so it works everywhere: Chrome 131+, Firefox 143+, Safari 18.4+, and Edge 131+. For older browsers, fall back to wrapper divs.
Use it for FAQs, documentation sidebars, expandable menus, pricing tiers, anything where the content needs to look different from the trigger. You get cleaner HTML, less CSS, full control over the content area, and no semantic compromises.
---
--- title: View Transitions API: Smooth animations between DOM states description: Create animated transitions between different states of your app without complex animation libraries. topics: ['webdev', 'css'] ---
# View Transitions API: Smooth animations between DOM states
The View Transitions API makes your web app feel native without animation libraries.
It creates smooth transitions between different DOM states. When a user clicks a thumbnail to see the full image, or filters a list, or navigates between pages—the browser handles the animation automatically.
Before this API, you'd reach for libraries like Framer Motion or React Spring to animate DOM changes. Those libraries work, but they add bundle size, require learning new APIs, and can be complex to integrate. The View Transitions API is built into the browser and works with any framework—or no framework at all.
The browser does the heavy lifting. You tell it when the DOM changes. It captures what the page looked like before, what it looks like after, and animates between those two states. No manual keyframe management, no calculating element positions, no performance optimization needed.
## How it works
Call `document.startViewTransition()` and pass it a function that updates the DOM. The browser captures a snapshot of the old state, executes your function, captures the new state, and animates between them.
The API works in three phases. First, the browser takes a screenshot of the current page. Second, it calls your callback function to update the DOM. Third, it takes another screenshot and animates from the old screenshot to the new one. This happens in a single frame, so users never see the instant DOM change—only the smooth animation.
```javascript
function updateView() {
// Check browser support
if (!document.startViewTransition) {
updateTheDOMSomehow();
return;
}
// Wrap DOM changes in a transition
document.startViewTransition(() => updateTheDOMSomehow());
}
```
The default animation is a cross-fade. No CSS required. The browser figures out what changed and animates it.
**Try it:** Click the button below to see the basic transition in action.
The transition returns a promise that resolves when the animation completes. Use this to chain actions or trigger cleanup:
```javascript
const transition = document.startViewTransition(() => {
document.querySelector('#content').textContent = 'New content';
});
// Wait for the animation to finish
await transition.ready;
console.log('Transition started');
await transition.finished;
console.log('Transition completed');
```
If your callback throws an error or returns a rejected promise, the transition aborts. The DOM still updates, but without animation. This fail-safe behavior means transitions enhance the experience without breaking core functionality.
For multi-page apps, add this CSS to enable transitions between different pages:
```css
@view-transition {
navigation: auto;
}
```
Now when users click links, the browser animates between pages instead of doing a hard navigation. This works in Chrome 126+, Edge 126+, and Safari 18.2+.
You can customize which navigations trigger transitions. Use JavaScript to control this per-navigation:
```javascript
navigation.addEventListener('navigate', (event) => {
if (shouldNotTransition(event)) {
return; // Skip transition
}
event.intercept({
handler: async () => {
document.startViewTransition(() => {
// Load new content
});
}
});
});
```
## Custom animations
Use CSS pseudo-elements to control how transitions look. The API creates several pseudo-elements during each transition:
- `::view-transition` - the root overlay containing all transitions
- `::view-transition-group()` - groups related elements
- `::view-transition-image-pair()` - contains both old and new snapshots
- `::view-transition-old()` - snapshot of the previous state
- `::view-transition-new()` - live view of the new state
These pseudo-elements exist only during the transition. The browser creates them, animates them, then removes them. You can style them like any CSS element—transform, opacity, filter, whatever you need.
```css
::view-transition-old(root) {
animation: slide-out-left 0.3s ease-out;
}
::view-transition-new(root) {
animation: slide-in-right 0.3s ease-out;
}
@keyframes slide-out-left {
to { transform: translateX(-100%); }
}
@keyframes slide-in-right {
from { transform: translateX(100%); }
}
```
This creates a slide transition instead of the default fade.
**Try it:** Navigate through the steps to see the custom slide animation.
For specific elements, use `view-transition-name` in CSS to create named transitions:
```css
.thumbnail {
view-transition-name: product-image;
}
.fullscreen {
view-transition-name: product-image;
}
```
When an element with `view-transition-name: product-image` disappears and another appears with the same name, the browser morphs between them. The thumbnail smoothly expands into the fullscreen view.
The browser tracks the element's position, size, and other properties, then animates from the old state to the new state. This works even when the elements are completely different—one could be a 200px thumbnail and the other a full-screen modal. The browser figures out the transformation needed.
**Try it:** Click any image to see it smoothly morph into fullscreen.
Named transitions work because the browser matches elements by their `view-transition-name`. If an element with `product-image` exists before and after the transition, the browser knows they're related and creates a morph animation. If only one exists, it fades in or out. If multiple elements have the same name, the last one wins.
You can animate multiple elements independently by giving them unique names:
```css
.card-1 { view-transition-name: card-1; }
.card-2 { view-transition-name: card-2; }
.card-3 { view-transition-name: card-3; }
```
Each named element gets its own pseudo-element tree. You can style them separately:
```css
::view-transition-old(card-1) {
animation: fade-out 0.3s ease-out;
}
::view-transition-new(card-1) {
animation: fade-in 0.3s ease-in;
}
```
This also works for animating list reordering. Give each item a unique `view-transition-name` and the browser animates them to their new positions:
```css
.list-item-1 { view-transition-name: item-1; }
.list-item-2 { view-transition-name: item-2; }
.list-item-3 { view-transition-name: item-3; }
```
**Try it:** Click shuffle to see items animate to their new positions.
The browser calculates the shortest path between old and new positions for each named element. Items that move animate smoothly to their new locations. Items that disappear fade out. Items that appear fade in. All coordinated automatically.
Dynamic transition names work with JavaScript. Generate names based on data:
```javascript
items.forEach((item, index) => {
const element = document.querySelector(`#item-${item.id}`);
element.style.viewTransitionName = `item-${item.id}`;
});
document.startViewTransition(() => {
// Reorder items
items.sort(() => Math.random() - 0.5);
renderItems(items);
});
```
Each item keeps its identity across the transition. The browser tracks where each one moves and animates it there.
## Performance considerations
View Transitions are fast. The browser captures bitmap snapshots of elements, which is cheaper than animating actual DOM nodes. Snapshots render on the GPU, so animations run at 60fps even on slower devices.
The snapshots are temporary. They exist only during the transition—usually 200-400ms. After the animation completes, they're discarded. This means no memory leaks or lingering overhead.
Transitions don't block the main thread. The browser captures snapshots, starts the animation, then continues executing JavaScript. Your app stays responsive during transitions.
If a transition takes too long, the browser caps it. Transitions longer than 1 second are suspicious—usually a sign of forgotten cleanup. The browser enforces reasonable limits to prevent stuck states.
## Using with frameworks
React has experimental support via `unstable_ViewTransition`:
```jsx
function Item() {
return (
Content here
);
}
export default function Component() {
const [showItem, setShowItem] = useState(false);
return (
<>
{
startTransition(() => {
setShowItem(prev => !prev);
});
}}>
Toggle
{showItem ? : null}
>
);
}
```
The experimental React API requires installing React canary builds:
```bash
npm install react@experimental react-dom@experimental
```
Don't use this in production. The API is unstable and will change.
Vue, Svelte, and other frameworks work with the standard API. No special wrappers needed—just call `document.startViewTransition()` when state changes. Most frameworks trigger DOM updates synchronously within the callback, which is exactly what the API expects.
For SPAs using client-side routing, wrap route changes in transitions:
```javascript
// With React Router
navigate('/new-page');
// becomes
document.startViewTransition(() => {
navigate('/new-page');
});
// With Vue Router
router.push('/new-page');
// becomes
document.startViewTransition(() => {
router.push('/new-page');
});
```
Astro has built-in support through ``. Add the component to your layout and page transitions happen automatically. You can customize transition names per-page using the `transition:name` directive.
## Common patterns
Conditional transitions based on user preference:
```javascript
function updateWithTransition(updateFn) {
if (prefersReducedMotion()) {
updateFn();
return;
}
document.startViewTransition(updateFn);
}
```
Skip transitions for fast repeated actions:
```javascript
let lastTransition = 0;
function updateIfReady(updateFn) {
const now = Date.now();
if (now - lastTransition < 300) {
updateFn(); // Too soon, skip transition
return;
}
lastTransition = now;
document.startViewTransition(updateFn);
}
```
Different transitions for different actions:
```javascript
document.documentElement.dataset.transition = 'slide';
document.startViewTransition(() => {
navigate('/next');
});
// In CSS
:root[data-transition='slide'] ::view-transition-old(root) {
animation: slide-out 0.3s;
}
```
## Browser support
Same-document transitions work in Chrome 111+, Edge 111+, Firefox 133+, and Safari 18+. Same-document view transitions reached Baseline Newly Available status in October 2025.
Cross-document transitions (the multi-page app feature) work in Chrome 126+, Edge 126+, and Safari 18.2+. Firefox doesn't support it yet.
The API degrades gracefully. If the browser doesn't support it, your DOM updates still work—they just don't animate. This makes it safe to use today. Check for support with `'startViewTransition' in document` before calling.
The API is stable. Chrome shipped it in 2023. Other browsers followed. It's not experimental anymore—it's production-ready. Use it for product galleries, navigation between pages, filtering lists, expanding cards, or any UI change where you want smooth motion instead of instant replacement.
The View Transitions API handles the animation complexity so you don't have to. No more manual position tracking, no more complex state management, no more animation libraries for basic transitions. The browser does it all.
---
--- title: Chrome DevTools MCP: Let Your AI Agent Debug Your App description: Give your AI agent access to your running application. They can see errors, inspect the network tab, check the DOM, and debug issues while you work. topics: ['llm'] ---
# Chrome DevTools MCP: Let Your AI Agent Debug Your App
Your AI assistant can't see what's happening in your browser. You describe a bug, they guess at fixes, and it's usually wrong because they don't have the actual error message or network data.
[Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp) fixes that. It gives your AI agent access to your running app. They can take screenshots, see what's in the DOM, check network requests, run JavaScript, and see console errors. Same stuff you see in DevTools.
You install an MCP server that talks to your browser. Now when you describe a bug, your AI agent can actually see it instead of guessing.
Here's what happens in practice. You tell Claude "there's a form that won't submit." Claude takes a screenshot, inspects the network tab, sees the API returned a 401, checks the error handler code, and tells you exactly what's broken. No more guessing. No more "have you tried...?"
## Installation
**Cursor / Claude:**
```json title="~/.cursor/mcp.json"
{
"mcpServers": {
"chrome-devtools-mcp": {
"command": "npx",
"args": ["@anthropic-ai/chrome-devtools-mcp"]
}
}
}
```
**VS Code + Claude:**
```json title="~/.vscode/claude.json"
{
"mcpServers": {
"chrome-devtools-mcp": {
"command": "npx",
"args": ["@anthropic-ai/chrome-devtools-mcp"]
}
}
}
```
**OpenAI ChatGPT / ChatGPT Canvas:**
```json title="~/.openai/mcp.json"
{
"mcpServers": {
"chrome-devtools-mcp": {
"command": "npx",
"args": ["@anthropic-ai/chrome-devtools-mcp"]
}
}
}
```
**Google Gemini:**
```json title="~/.gemini/mcp.json"
{
"servers": {
"chrome-devtools-mcp": {
"command": "npx",
"args": ["@anthropic-ai/chrome-devtools-mcp"]
}
}
}
```
**GitHub Copilot:**
```json title="~/.github/copilot-settings.json"
{
"mcpServers": {
"chrome-devtools-mcp": {
"command": "npx",
"args": ["@anthropic-ai/chrome-devtools-mcp"]
}
}
}
```
**JetBrains IDEs (IntelliJ, WebStorm, PyCharm):**
```json title="~/.jetbrains/mcp.json"
{
"mcpServers": {
"chrome-devtools-mcp": {
"command": "npx",
"args": ["@anthropic-ai/chrome-devtools-mcp"]
}
}
}
```
After adding the config, restart your editor. In your next conversation, tell your AI agent: "Use Chrome DevTools MCP to inspect my app at localhost:3000." It'll ask for permission (for safety), connect to your browser, and start debugging.
The value is immediate. Your AI agent goes from making educated guesses to seeing actual errors. When something breaks in production but works locally, or performance tanks under load, your agent can now see what you see and debug in real-time.
---
--- title: Claude Code Superpowers: How to Add Skills That Plan Before Coding description: Superpowers is an MCP plugin for Claude Code that enforces planning, TDD, and debugging workflows. Setup guide with real examples. topics: ['llm'] ---
# Claude Code Superpowers: How to Add Skills That Plan Before Coding
I've been building [skillcraft.ai](https://skillcraft.ai) with Claude for the past few months. Claude's great at writing code fast, but it has a problem: it skips steps constantly.
I'll ask Claude to help migrate something big, and it starts suggesting changes immediately. No planning phase. No "let me find every file that needs updating first." Just diving straight into code changes and hoping for the best.
That's how you miss files. That's how you ship bugs.
[Superpowers](https://github.com/obra/superpowers) is an MCP that fixes this. It's basically a library of structured workflows—testing, debugging, planning—that [Claude Code](https://claude.ai/code) loads automatically and actually follows.
A [skill](https://claude.com/blog/skills) in Claude Code is a folder with instructions, scripts, and resources that Claude loads when needed. It's part of Anthropic's Agent Skills feature that works across Claude apps, Claude Code, and the API. Each skill defines when it applies, what process to follow, and what shortcuts not to take. When you start a task that matches a skill, Claude scans available skills, finds the match, and loads it automatically.
I use three slash commands constantly:
| Command | What it does |
|---------|--------------|
| `/superpowers:brainstorm` | Refining rough ideas before coding |
| `/superpowers:write-plan` | Creating detailed implementation plans |
| `/superpowers:execute-plan` | Running plans in batches with review checkpoints |
I added Superpowers to my `CLAUDE.md` so it loads automatically on every session:
```markdown
# Project Setup
Use the Superpowers MCP for all development work. Load it at session start.
```
This is huge for token efficiency—instead of Claude burning through context trying to hold everything in memory, it splits work into 5-minute chunks and writes progress to markdown files. You never lose context between sessions because the plan is right there in a file, not locked in some conversation that hit the token limit three hours ago.
Here's what the generated files look like:
```
~/.config/superpowers/
└── plans/
└── nextjs-16-migration/
├── PLAN.md # Complete migration roadmap
├── progress.md # Current status and completed tasks
└── verification.md # Test commands and success criteria
```
The `PLAN.md` file contains everything:
| Section | What it includes |
|---------|------------------|
| Overview | What needs to change and why |
| Phase 1 | API route refactoring (23 files) |
| Phase 2 | Component time-sensitivity fixes |
| Phase 3 | Context provider Suspense boundaries |
| Phase 4 | Enable cacheComponents |
| Phase 5 | Testing & verification |
| Rollback | What to do if it breaks |
Skillcraft runs on Next.js, and I wanted to enable the new `cacheComponents` feature in [Next.js 16](/blog/whats-new-in-nextjs-16). This thing breaks patterns everywhere—API routes that access `searchParams`, components using `new Date()`, context providers without Suspense boundaries.
I ran `/superpowers:write-plan` and got back a 500-line plan. Not some vague outline, but a complete roadmap: all 23 API route files that needed changes, the two components using `new Date()` that would break prerendering, specific context providers needing Suspense boundaries, and a 4-day timeline with testing checkpoints.
The plan included verification commands for each phase:
```bash
# Test specific endpoints after API refactoring
curl http://localhost:3000/api/leaderboard
curl http://localhost:3000/api/courses/recent
curl http://localhost:3000/api/topics
```
It documented before/after patterns:
```typescript
// Before: Incompatible with cacheComponents
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
// After: Clean (API routes are dynamic by default)
// Note: With cacheComponents enabled, API routes are dynamic by default
```
It even defined success criteria (build succeeds, CLS stays at 0.000, Lighthouse score ≥ 95) and a rollback plan.
Without this, I would've enabled cacheComponents, hit errors, fixed them one by one, and definitely missed edge cases. The migration would've taken days of reactive debugging. With the plan, I had a complete roadmap before touching any code.
The library includes multiple skills for testing, debugging, and development workflows:
| Skill | What it enforces |
|-------|------------------|
| `test-driven-development` | RED-GREEN-REFACTOR: write test, watch it fail, write code |
| `systematic-debugging` | 4-phase approach: root cause investigation → pattern analysis → hypothesis testing → implementation |
| `verification-before-completion` | Run verification commands and confirm output before claiming work is done |
These skills literally block you from skipping steps. No more "I think it works" without proof.
If you're doing a migration and need to find every instance of a pattern, Superpowers will find them all. If you're debugging and about to guess at a fix, it stops you and makes you investigate the root cause first. If missing one file will break production, it makes you verify everything before you're done.
## Installation
Superpowers works with Claude Code (the CLI tool). Install it via the Plugin Marketplace:
```bash
# In Claude Code
/plugin marketplace add obra/superpowers-marketplace
/plugin install superpowers@superpowers-marketplace
```
Or add it manually to `.claude/plugins.json`:
```json
{
"plugins": {
"superpowers": {
"type": "github",
"owner": "obra",
"repo": "superpowers"
}
}
}
```
When Claude Code starts, you'll see confirmation that skills loaded. Then just use the slash commands: `/superpowers:brainstorm` before starting complex features, `/superpowers:write-plan` for migrations or multi-file refactors, `/superpowers:execute-plan` to run those plans in batches.
---
--- title: Lighthouse CI: Catch Performance Regressions Before They Ship description: Lighthouse CI fails your builds when performance drops. I run it on every pull request. topics: ['web performance', 'webdev'] ---
# Lighthouse CI: Catch Performance Regressions Before They Ship
Lighthouse CI solves a specific problem: you fix performance issues, merge the PR, then someone adds a 500KB image next week and performance tanks again.
Lighthouse CI is a command-line tool that runs Google Lighthouse audits in your CI pipeline. It tests your site against performance budgets and fails the build if metrics drop below thresholds.
### Installation
```bash
npm install -g @lhci/cli@latest
```
Create `lighthouserc.json` in your project root. I'm using Astro, so the default port is 4321:
```json
{
"ci": {
"collect": {
"url": [
"http://localhost:4321/",
"http://localhost:4321/blog"
],
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:performance": ["error", {"minScore": 0.9}],
"first-contentful-paint": ["error", {"maxNumericValue": 2000}],
"largest-contentful-paint": ["error", {"maxNumericValue": 2500}],
"cumulative-layout-shift": ["error", {"maxNumericValue": 0.1}]
}
}
}
}
```
### Running Locally First
Start by running Lighthouse CI locally. This lets you set realistic performance budgets before adding it to your pipeline.
```bash
npm run dev
lhci autorun
```
The `autorun` command runs three audits per URL, takes the median scores, and checks them against your assertions. When audits fail, you'll see exactly which metrics are over budget.
This is where you tune your configuration. If your site consistently scores 0.85 on performance but you set `minScore: 0.9`, every build fails. Run locally, see what scores you actually get, then set budgets slightly above your current performance. The goal is to prevent regressions, not to fail every build.
For static sites, point directly to your build output:
```json
{
"ci": {
"collect": {
"staticDistDir": "./dist"
}
}
}
```
For apps that need a running server, tell Lighthouse CI how to start it:
```json
{
"ci": {
"collect": {
"url": ["http://localhost:3000/"],
"startServerCommand": "npm run preview"
}
}
}
```
Once your assertions pass locally, add Lighthouse CI to your pipeline.
### Framework-Specific Setup
**Next.js:**
Next.js uses port 3000 by default. Your configuration needs `npm run build` to generate the production build, then `npm start` to serve it:
```json
{
"ci": {
"collect": {
"url": ["http://localhost:3000/"],
"startServerCommand": "npm run build && npm start"
}
}
}
```
For static exports (`output: 'export'` in `next.config.js`), use `staticDistDir`:
```json
{
"ci": {
"collect": {
"staticDistDir": "./out"
}
}
}
```
**Nuxt:**
Nuxt uses port 3000 by default. Build with `npm run build`, then preview with `npm run preview` (which uses `nuxi preview` internally):
```json
{
"ci": {
"collect": {
"url": ["http://localhost:3000/"],
"startServerCommand": "npm run build && npm run preview"
}
}
}
```
For static generation (`nuxi generate`), point to the output directory:
```json
{
"ci": {
"collect": {
"staticDistDir": "./.output/public"
}
}
}
```
### Adding to CI
**GitHub Actions** (`.github/workflows/lighthouse.yml`):
```yaml
name: Lighthouse CI
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build site
run: npm run build
- name: Serve site
run: npm run preview &
- name: Wait for server
run: npx wait-on http://localhost:4321
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli@latest
lhci autorun
```
**GitLab CI** (`.gitlab-ci.yml`):
```yaml
lighthouse:
image: cypress/browsers:node16.17.0-chrome106
script:
- npm ci
- npm run build
- npm run preview &
- npx wait-on http://localhost:4321
- npm install -g @lhci/cli@latest
- lhci autorun
only:
- merge_requests
```
### Performance Budgets
The `assertions` block defines what passes and what fails.
**Score-based budgets:**
```json
"categories:performance": ["error", {"minScore": 0.9}]
```
Performance score must be 90 or above. Lighthouse scores range from 0 to 1.
**Metric-based budgets:**
```json
"first-contentful-paint": ["error", {"maxNumericValue": 2000}]
```
First Contentful Paint must be under 2000ms. This is more reliable than scores because it tests actual render timing.
**Disabling audits:**
```json
"uses-responsive-images": "off",
"offscreen-images": "off"
```
Turn off audits that don't apply to your use case. Responsive image warnings are often false positives for modern image formats.
### What Happens When It Fails
```bash
Checking assertions against 2 URL(s), 3 run(s) each.
✘ http://localhost:4321/
categories:performance failure for minScore assertion
expected: >= 0.9
found: 0.87
largest-contentful-paint failure for maxNumericValue assertion
expected: <= 2500
found: 3200
Assertion failed. Exiting with status code 1.
```
The build fails. Fix the performance issue, push again, repeat.
### Running Lighthouse CI with Desktop Settings
Lighthouse CI runs mobile audits by default. Mobile simulates a slower device with 4x CPU slowdown and slow 3G network conditions. For desktop testing, configure the `preset` in your settings:
```json
{
"ci": {
"collect": {
"settings": {
"preset": "desktop"
}
}
}
}
```
Desktop audits use faster throttling and no CPU slowdown. Test the device type your users actually use.
### Storage Options
```json
"upload": {
"target": "temporary-public-storage"
}
```
This stores reports at `https://googlechrome.github.io/lighthouse-ci/viewer/` for 7 days. The URL appears in CI logs.
For permanent storage, use the Lighthouse CI Server or store results as build artifacts.
### When It's Most Valuable
Lighthouse CI catches regressions that code review misses. A developer adds `moment.js` (231KB). Code looks fine. CI fails because First Contentful Paint jumped from 1.8s to 3.2s.
Without CI, that ships to production. With CI, you catch it in the PR and suggest `date-fns` (13KB) instead.
It works best for content sites, marketing pages, and documentation. It's less useful for dashboards and apps where performance varies based on data.
---
--- title: Next.js DevTools MCP: Your Development Server Just Got Smarter description: The Next.js DevTools MCP connects Claude and Cursor to your running dev server. I use it every day. topics: ['llm'] ---
# Next.js DevTools MCP: Your Development Server Just Got Smarter
The Next.js DevTools MCP solves a specific problem: coding assistants can read your files, but they can't see if your app is actually running or what errors you're getting.
[Next.js 16+](/blog/whats-new-in-nextjs-16) includes a built-in MCP endpoint at `/_next/mcp`. The [next-devtools-mcp](https://github.com/vercel/next-devtools-mcp) package discovers this endpoint and exposes live build errors, runtime errors, TypeScript type errors, application routes, page metadata, Server Action definitions, dev server logs, and project configuration.
### Installation
**Claude Code:**
```bash
claude mcp add next-devtools npx next-devtools-mcp@latest
```
**OpenAI Codex:**
```bash
codex mcp add next-devtools -- npx next-devtools-mcp@latest
```
**Google Gemini:**
```bash
gemini mcp add next-devtools npx next-devtools-mcp@latest
```
**Other editors** (`.mcp.json` in project root):
```json
{
"mcpServers": {
"next-devtools": {
"command": "npx",
"args": ["-y", "next-devtools-mcp@latest"]
}
}
}
```
Restart your dev server. The package auto-discovers the `/_next/mcp` endpoint.
### Live Error Access
Before:
```bash
Error: Cannot read property 'map' of undefined
at Page (app/posts/page.tsx:12)
# You copy/paste the error into Claude
```
After:
```bash
# Claude queries get_errors automatically and sees:
# - Full stack trace + error type
# - Your project structure (get_project_metadata)
# - The actual code in app/posts/page.tsx
```
Claude checks your project config, searches the docs, writes code, verifies no errors, tests it in the browser, and confirms it works.
The difference: Claude sees your running app instead of guessing from static files.
### Available Tools
| Tool | What It Does |
|------|-------------|
| `get_page_metadata` | Returns routes, components, and page structure |
| `get_project_metadata` | Exposes project configuration and dependencies |
| `get_server_action_by_id` | Queries specific Server Actions |
| `get_logs` | Access to dev server logs |
| `nextjs_docs` | Searches official Next.js docs for version-specific patterns |
| `upgrade_nextjs_16` | Runs codemods for Next.js 16 upgrades |
| `enable_cache_components` | Configures Cache Components with pre-flight checks |
| `browser_eval` | Playwright integration for browser testing |
[Next.js 16+](/blog/whats-new-in-nextjs-16) gets full support (errors, state, logs). Next.js 15 and below get partial support (upgrades, docs, browser testing).
---
--- title: Context7 MCP: Stop LLM Hallucinations with Live Docs description: Context7 MCP server pulls version-specific library docs into Claude Code, Cursor, and other AI editors. Setup guide and how it works. topics: ['llm', 'webdev', 'tech'] ---
# Context7 MCP: Stop LLM Hallucinations with Live Docs
The [Context7 MCP](https://github.com/upstash/context7) server solves a specific problem: LLMs have stale training data. When you're working with Next.js 15, React 19, or any library that's evolved since the model's cutoff date, you get hallucinated APIs and deprecated patterns.
Context7 is an MCP server that injects up-to-date documentation directly into your LLM's context window. It pulls version-specific docs and code examples from a curated database of library documentation.
The server exposes two MCP tools:
**resolve-library-id**: Takes a library name (e.g., "next.js") and returns a Context7 ID (e.g., "/vercel/next.js/v15.0.0"). It matches against a database of libraries, prioritizing by trust score and documentation coverage.
**get-library-docs**: Takes a Context7 ID and optional topic filter, returns relevant documentation chunks and code examples. You can specify token limits (default 5000, configurable up to your context window).
When your LLM needs library information, it calls these tools automatically. The documentation gets injected into the prompt, so responses are based on current APIs instead of guessing.
Two transport options: HTTP (remote) or stdio (local).
**Claude Code:**
```bash
# Remote (HTTP transport)
claude mcp add --transport http context7 https://mcp.context7.com/mcp \
--header "CONTEXT7_API_KEY: YOUR_API_KEY"
# Local (stdio transport)
claude mcp add context7 -- npx -y @upstash/context7-mcp --api-key YOUR_API_KEY
```
**VS Code / Cursor** (`~/.cursor/mcp.json`):
```json
{
"mcpServers": {
"context7": {
"command": "npx",
"args": ["-y", "@upstash/context7-mcp", "--api-key", "YOUR_API_KEY"]
}
}
}
```
**Windsurf:**
```json
{
"mcpServers": {
"context7": {
"serverUrl": "https://mcp.context7.com/mcp",
"headers": {
"CONTEXT7_API_KEY": "YOUR_API_KEY"
}
}
}
}
```
Get an API key at [context7.com](https://context7.com).
To make your AI agent automatically use Context7 before planning and coding, add this rule to your project:
**Claude Code** (`CLAUDE.md`):
```markdown
## Context7 Integration
Always use Context7 MCP tools before planning or implementing code that involves external libraries or frameworks:
1. Use `resolve-library-id` to get the correct library identifier
2. Use `get-library-docs` to pull current documentation
3. Base all code suggestions on the retrieved documentation, not training data
This applies to any library usage, API integration, or framework-specific patterns.
```
**Cursor** (`.cursorrules` or Cursor Settings > Rules for AI):
```
Always use Context7 for library documentation:
- Before suggesting code for any external library, use resolve-library-id and get-library-docs
- Never rely on training data for framework APIs (Next.js, React, Vue, etc.)
- Pull docs first, then code
- Use version-specific documentation when available
```
**Windsurf** (`.windsurfrules`):
```
Use Context7 MCP for all library/framework code:
1. Resolve library ID first
2. Fetch current docs with get-library-docs
3. Use retrieved docs for code generation
4. Never guess API patterns from training data
```
This ensures your AI always pulls fresh docs before writing code instead of hallucinating from stale training data.
Say you ask your AI to implement data fetching in Next.js 16. Without Context7, it might suggest:
```typescript
// Outdated Pages Router pattern (deprecated)
export async function getServerSideProps() {
const res = await fetch('https://api.example.com/data')
const data = await res.json()
return { props: { data } }
}
export default function Page({ data }) {
return {data.title}
}
```
With Context7, it queries `/vercel/next.js` (resolves to latest version), gets current docs, and suggests:
```typescript
// Current App Router pattern (async Server Components)
export default async function Page() {
const data = await fetch('https://api.example.com/data')
const posts = await data.json()
return (
{posts.map((post) => (
{post.title}
))}
)
}
```
The difference: one works with the current App Router, one doesn't. Context7 pulled actual API patterns from Next.js 16 docs instead of hallucinating from stale training data.
Most valuable with frameworks that iterate quickly: Next.js, React, Vue, Astro. Also helpful when exploring libraries you haven't used before—you get working examples on first try instead of debugging hallucinated method signatures.
You can skip the resolution step and specify exact library IDs in your prompts:
```text
Use library /vercel/next.js/v15.4.0-canary.82 for implementing the app router.
```
This bypasses `resolve-library-id` and goes straight to the version-specific docs you need.
---
--- title: IndexNow: Get Pages Indexed in Minutes, Not Weeks description: Set up IndexNow to notify Bing, DuckDuckGo, ChatGPT, and Perplexity the moment you publish. Free protocol, 5-minute setup. topics: ['webdev'] ---
# IndexNow: Get Pages Indexed in Minutes, Not Weeks
If you're still waiting days or weeks for search engines to discover your new content, you're missing out on one of the web's best-kept secrets: [IndexNow](https://www.indexnow.org/)
IndexNow is a protocol that lets you instantly notify search engines when your content changes. Instead of waiting for crawlers to stumble upon your updates, you proactively tell them "hey, I just published something new!" The beauty of IndexNow is its simplicity: submit a URL once, and all participating search engines including Microsoft Bing, DuckDuckGo, and others get notified automatically. Think of it as a notification system for the web.
Google doesn't support IndexNow yet, so for Google you'll still rely on traditional XML sitemaps and their own indexing API. But for the rest of the search ecosystem, IndexNow is a game changer.
Traditional search engine crawling is inefficient. Search engines waste resources crawling unchanged pages while your new content sits undiscovered for days or weeks, and you have no control over when crawlers visit your site. With IndexNow, new content gets indexed in hours (sometimes minutes), search engines only crawl what actually changed, you control when to notify search engines, and it's completely free and easy to implement.
The workflow is straightforward. You generate an API key which is just a random string, host a verification file at your domain root, submit URLs to IndexNow API when content changes, and IndexNow shares your submission with all partner search engines. No OAuth flows, no complicated authentication, no rate limits to worry about within reason.
### Setting Up IndexNow
This guide includes implementation examples for multiple frameworks and platforms. Click on your technology below to jump directly to its implementation guide:
| Technology | Jump to Section |
|-----------|-----------------|
| [Astro](#astro-implementation) | Build-time integration |
| [React](#react-implementation) | Custom hooks |
| [Next.js](#nextjs-implementation) | API routes & Server Components |
| [Vue](#vue-implementation) | Composables |
| [Nuxt](#nuxt-implementation) | Server API |
| [Remix](#remix-implementation) | Actions & Loaders |
| [SvelteKit](#sveltekit-implementation) | Server routes |
| [Angular](#angular-implementation) | Services & DI |
| [Django](#django-implementation) | Views & JsonResponse |
| [Flask](#flask-implementation) | Routes & jsonify |
| [FastAPI](#fastapi-implementation) | POST endpoints & Pydantic |
| [Express.js](#expressjs-implementation) | Routes & Middleware |
| [Laravel](#laravel-implementation) | Routes & Controllers |
| [Ruby on Rails](#ruby-on-rails-implementation) | Controllers & Routes |
| [Spring Boot](#spring-boot-implementation) | RestController & PostMapping |
| [ASP.NET Core](#aspnet-core-implementation) | Controllers & IActionResult |
| [WordPress](#wordpress-implementation) | REST API endpoints |
| [Vanilla JS](#vanilla-javascript-implementation) | Direct API calls |
### Generating Your IndexNow API Key
The first step in setting up IndexNow is generating your API key. This key proves to search engines that you own the domain and authorizes your IndexNow submissions.
Your API key should be a random hexadecimal string between 8-128 characters. The simplest way to generate one is using openssl:
```bash title="Bash"
openssl rand -hex 16
```
This generates something like `19942de05a08448b2f69abd9cfa9f9b8`. Save this key because you'll need it for all IndexNow submissions.
Next, create a text file named exactly after your API key with a `.txt` extension in your `public` directory. This file proves to search engines that you own the domain:
```bash title="Bash"
echo "19942de05a08448b2f69abd9cfa9f9b8" > public/19942de05a08448b2f69abd9cfa9f9b8.txt
```
The file must be named exactly as your API key, located at your domain root so it's accessible at `https://yourdomain.com/[key].txt`, and contain only the API key with no extra whitespace or characters. After deployment, verify it's accessible:
```bash title="Bash"
curl https://yourdomain.com/19942de05a08448b2f69abd9cfa9f9b8.txt
```
You should see your API key returned with no extra formatting.
#### API Key Security Best Practices
The IndexNow API key sits in an unusual security position. Anyone can discover it by visiting `https://yourdomain.com/[key].txt` because search engines need to verify domain ownership. Yet you should still protect how your application uses this key. If someone gets hold of it and uses it in their own code, they could submit URLs on your behalf, trigger rate limiting with excessive submissions, or worse, submit malicious URLs that damage your domain's reputation with search engines. They could also track exactly when and what you're submitting.
**Version Control**
Never commit API keys to Git. Add `.env` to your `.gitignore` file and use `.env.example` with placeholder values for documentation. The verification file (`your-key.txt`) in the `public` directory is fine to commit since it needs to be publicly accessible anyway.
**Environment Variables**
Store your key as `INDEXNOW_API_KEY` in your `.env` file locally. Add it to your hosting platform's environment variables (Vercel, Netlify, whatever you're using). Never hardcode the key directly in your source code, even though it's technically public. The point isn't to hide the key itself but to control who can use it in your application.
**Endpoint Protection**
Add authentication to your IndexNow submission endpoints using API keys, OAuth, or JWT. Implement rate limiting to prevent abuse. Validate incoming requests and sanitize URLs before submitting them. Log all submissions so you can audit what went through and when.
**Monitoring**
Watch for unexpected submission patterns. Set up alerts for rate limit errors. Review your IndexNow submission logs regularly. If something looks off, rotate your API key immediately.
**Authentication Example**
```typescript title="src/pages/api/indexnow.ts"
export const POST: APIRoute = async ({ request }) => {
// Check for authorization header
const authHeader = request.headers.get('authorization');
const expectedToken = process.env.API_SECRET_TOKEN;
if (!authHeader || authHeader !== `Bearer ${expectedToken}`) {
return new Response(JSON.stringify({
success: false,
error: 'Unauthorized'
}), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
// Your IndexNow submission logic here...
const apiKey = process.env.INDEXNOW_API_KEY;
// ... rest of implementation
};
```
**Key Rotation**
If you suspect your IndexNow API key has been compromised, generate a new one using `openssl rand -hex 16`. Update the verification file in your `public` directory. Update `INDEXNOW_API_KEY` in all your environments (local, staging, production). Redeploy your application. Wait 24-48 hours before deleting the old verification file so search engines have time to update their records.
Treat your IndexNow API key like a password: keep it in environment variables, never commit it to source code, protect your endpoints with authentication, and watch for suspicious activity. The key itself must be publicly accessible via the verification file, but controlling who can use it in your application prevents abuse.
Now that you have your API key set up securely, choose your framework or technology from the table above to see the specific implementation guide.
### Astro Implementation
Astro's integration system allows you to hook into the build process and automatically submit URLs to IndexNow every time you deploy. Unlike manual submissions or client-side approaches, this method ensures complete coverage of your site by reading the generated sitemap and submitting all URLs without any intervention.
The integration works by tapping into Astro's `astro:build:done` hook, which fires after the build completes and all static files including your sitemap have been generated. This is the perfect timing to read your sitemap, extract all URLs, and submit them to IndexNow in a single batch operation.
This approach works well for content-heavy sites where you want guaranteed indexing of every page. Whether you're deploying to Vercel, Netlify, or any other platform, the integration runs automatically as part of your build process, so you never have to remember to manually submit URLs.
**Before implementing**, make sure you've generated your IndexNow API key using the instructions in the [Generating Your IndexNow API Key](#generating-your-indexnow-api-key) section above.
First, create a utility file to handle IndexNow submissions:
```typescript title="src/utils/indexnow.ts"
export interface IndexNowSubmission {
host: string;
key: string;
keyLocation?: string;
urlList: string[];
}
export interface IndexNowResponse {
success: boolean;
statusCode?: number;
message?: string;
error?: string;
}
export async function submitToIndexNow(
urls: string[],
apiKey: string,
host: string
): Promise {
// Validate inputs
if (!urls || urls.length === 0) {
return {
success: false,
error: 'No URLs provided'
};
}
if (urls.length > 10000) {
return {
success: false,
error: 'Too many URLs. Maximum is 10,000 per request.'
};
}
if (!apiKey || apiKey.length < 8 || apiKey.length > 128) {
return {
success: false,
error: 'Invalid API key. Must be between 8-128 characters.'
};
}
const payload: IndexNowSubmission = {
host,
key: apiKey,
urlList: urls
};
try {
const response = await fetch('https://api.indexnow.org/indexnow', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8'
},
body: JSON.stringify(payload)
});
// Handle responses
if (response.status === 200) {
return {
success: true,
statusCode: 200,
message: `Successfully submitted ${urls.length} URL(s) to IndexNow`
};
} else if (response.status === 202) {
return {
success: true,
statusCode: 202,
message: `URLs received and will be processed (${urls.length} URL(s))`
};
} else if (response.status === 400) {
return {
success: false,
statusCode: 400,
error: 'Bad request - Invalid format'
};
} else if (response.status === 403) {
return {
success: false,
statusCode: 403,
error: 'Forbidden - Key verification failed'
};
} else if (response.status === 422) {
return {
success: false,
statusCode: 422,
error: 'Unprocessable Entity - URLs not in host domain or limit exceeded'
};
} else if (response.status === 429) {
return {
success: false,
statusCode: 429,
error: 'Too Many Requests - Rate limit exceeded'
};
} else {
return {
success: false,
statusCode: response.status,
error: `Unexpected status code: ${response.status}`
};
}
} catch (error) {
return {
success: false,
error: `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
export async function submitSingleUrl(
url: string,
apiKey: string,
host: string
): Promise {
return submitToIndexNow([url], apiKey, host);
}
export async function submitInBatches(
urls: string[],
apiKey: string,
host: string,
chunkSize: number = 10000
): Promise {
const results: IndexNowResponse[] = [];
for (let i = 0; i < urls.length; i += chunkSize) {
const chunk = urls.slice(i, i + chunkSize);
const result = await submitToIndexNow(chunk, apiKey, host);
results.push(result);
// Add delay between batches to avoid rate limiting
if (i + chunkSize < urls.length) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
return results;
}
```
This utility provides three functions: `submitToIndexNow` for submitting multiple URLs, `submitSingleUrl` for convenience when submitting one URL, and `submitInBatches` for handling large URL lists with automatic chunking and rate limit protection.
Next, create an API endpoint for manual URL submissions:
```typescript title="src/pages/api/indexnow.ts"
export const prerender = false;
export const GET: APIRoute = async () => {
return new Response(JSON.stringify({
message: 'IndexNow API endpoint',
apiKey: process.env.INDEXNOW_API_KEY ? 'configured' : 'not configured',
usage: 'POST with JSON body: { "urls": ["https://example.com/page"] }'
}), {
status: 200,
headers: {
'Content-Type': 'application/json'
}
});
};
export const POST: APIRoute = async ({ request }) => {
const apiKey = process.env.INDEXNOW_API_KEY;
if (!apiKey) {
return new Response(JSON.stringify({
success: false,
error: 'INDEXNOW_API_KEY environment variable not set'
}), {
status: 500,
headers: {
'Content-Type': 'application/json'
}
});
}
try {
const body = await request.json();
const { urls } = body;
if (!urls || !Array.isArray(urls)) {
return new Response(JSON.stringify({
success: false,
error: 'Invalid request body. Expected { "urls": ["url1", "url2"] }'
}), {
status: 400,
headers: {
'Content-Type': 'application/json'
}
});
}
const result = await submitToIndexNow(urls, apiKey, 'yourdomain.com');
return new Response(JSON.stringify(result), {
status: result.success ? 200 : 500,
headers: {
'Content-Type': 'application/json'
}
});
} catch (error) {
return new Response(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}), {
status: 500,
headers: {
'Content-Type': 'application/json'
}
});
}
};
```
This endpoint allows you to manually submit URLs by making POST requests to `/api/indexnow`. The GET endpoint provides a status check showing whether your API key is configured.
Finally, create the Astro integration plugin for automatic submission:
```typescript title="src/plugins/indexNowSubmit.ts"
interface IndexNowConfig {
enabled?: boolean;
verbose?: boolean;
apiKey?: string;
}
export function indexNowSubmit(config: IndexNowConfig = {}): AstroIntegration {
const { enabled = true, verbose = true } = config;
return {
name: 'indexnow-submit',
hooks: {
'astro:build:done': async ({ dir }) => {
if (!enabled) {
if (verbose) {
console.log('[indexnow-submit] Skipped (disabled)');
}
return;
}
const apiKey = config.apiKey || process.env.INDEXNOW_API_KEY;
if (!apiKey) {
console.warn(
'[indexnow-submit] Skipped: INDEXNOW_API_KEY not set'
);
return;
}
try {
// Read sitemap
const sitemapPath = join(dir.pathname, 'sitemap-0.xml');
const sitemapContent = await readFile(sitemapPath, 'utf-8');
// Extract URLs
const urlMatches = sitemapContent.matchAll(/(.*?)<\/loc>/g);
const urls = Array.from(urlMatches, match => match[1]);
if (urls.length === 0) {
console.warn('[indexnow-submit] No URLs found in sitemap');
return;
}
if (verbose) {
console.log(`[indexnow-submit] Found ${urls.length} URLs`);
console.log('[indexnow-submit] Submitting to IndexNow...');
}
// Submit in batches
const results = await submitInBatches(urls, apiKey, 'yourdomain.com');
const successCount = results.filter(r => r.success).length;
const failCount = results.filter(r => !r.success).length;
if (verbose) {
console.log(
`[indexnow-submit] ✓ Completed: ${successCount} successful, ${failCount} failed`
);
}
if (successCount > 0) {
console.log(
`[indexnow-submit] ✓ Successfully submitted ${urls.length} URLs`
);
}
} catch (error) {
console.error(
`[indexnow-submit] Error: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
},
},
};
}
```
The integration function returns an Astro integration object with a `name` and `hooks` property. The hook function receives the build output directory, which it uses to locate the generated sitemap file. It then validates the API key exists, reads the sitemap XML, extracts all URLs using regex matching, and submits them in batches using the utility function we created earlier.
The configuration object accepts `enabled` and `verbose` flags for controlling behavior, plus an optional `apiKey` that falls back to environment variables. This flexibility lets you disable submissions in development or customize logging based on your deployment environment.
Now register the integration in your Astro configuration:
```javascript title="astro.config.mjs"
export default defineConfig({
integrations: [
indexNowSubmit({
enabled: true, // Set to false to disable
verbose: true // Set to false for minimal logging
})
]
});
```
With this configuration in place, every time you run `npm run build` or `yarn build`, the integration will automatically read your sitemap and submit all URLs to IndexNow. You'll see console output showing how many URLs were found and whether the submission succeeded, giving you immediate feedback during deployment.
The integration handles errors gracefully by catching and logging them without breaking your build process. If the sitemap doesn't exist or the API key isn't set, it will log a warning and continue, ensuring your builds don't fail due to IndexNow issues.
### React Implementation
React applications can integrate IndexNow through custom hooks that trigger submissions when content changes. The key is to create a reusable hook that handles the API call and manages the submission state. This approach works well for client-side rendered React apps, React with server-side rendering, or Create React App setups.
The hook pattern makes it easy to trigger IndexNow submissions from anywhere in your component tree. You can call it when publishing new blog posts, updating pages, or whenever content changes that should be indexed. The hook accepts a URL and a boolean flag that determines whether to submit, giving you full control over when submissions happen.
Start by creating the custom hook:
```typescript title="hooks/useIndexNow.ts"
export function useIndexNow(url: string, shouldSubmit: boolean) {
useEffect(() => {
if (!shouldSubmit) return;
fetch('/api/indexnow', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ urls: [url] })
})
.then(res => res.json())
.then(result => {
if (result.success) {
console.log('Submitted to IndexNow:', url);
}
})
.catch(console.error);
}, [url, shouldSubmit]);
}
```
This hook uses the `useEffect` dependency array to automatically trigger submissions when either the URL or the `shouldSubmit` flag changes. The early return prevents unnecessary API calls when `shouldSubmit` is false.
Now use it in your components when publishing new content:
```typescript title="components/BlogPost.tsx"
function BlogPost({ url, justPublished }) {
useIndexNow(url, justPublished);
return (
My Blog Post
{/* your content */}
);
}
```
The `justPublished` prop would be set to `true` immediately after creating or updating content, triggering the IndexNow submission. You might set this based on query parameters, state management, or after a successful save operation. For example, if you're using React Query or Redux, you could trigger the submission after a successful mutation or action.
### Next.js Implementation
Next.js applications benefit from IndexNow integration at multiple levels including API routes, server actions, and the app router. The framework's hybrid nature means you can handle IndexNow submissions both server-side and client-side depending on your needs.
For API routes in the pages directory, create an endpoint that accepts URL submissions:
```typescript title="pages/api/indexnow.ts"
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { urls } = req.body;
const result = await submitToIndexNow(urls, process.env.INDEXNOW_API_KEY!, 'yourdomain.com');
res.status(result.success ? 200 : 500).json(result);
}
```
This API route handles POST requests and validates the request method before processing. It extracts URLs from the request body and passes them to the IndexNow utility function along with your API key from environment variables. The response includes the full result object so clients can handle errors appropriately.
For Next.js 13+ with the app router, you can use route handlers instead. Create a file at `app/api/indexnow/route.ts` with similar logic but using the new Route Handler API. Server Actions are another option where you can directly call IndexNow from server components without needing a separate API endpoint.
You can also integrate IndexNow into your build process using `next.config.js`. Add a plugin that reads your sitemap after build and automatically submits all URLs, similar to the Astro integration shown earlier.
### Vue Implementation
Vue 3 applications work best with composables that follow the Composition API pattern. The composable approach provides reactive integration with Vue's reactivity system, making it easy to trigger IndexNow submissions based on component state changes.
Create a composable that watches for changes and submits URLs:
```typescript title="composables/useIndexNow.ts"
export function useIndexNow(url: Ref, shouldSubmit: Ref) {
watch(shouldSubmit, async (submit) => {
if (!submit) return;
try {
const response = await fetch('/api/indexnow', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ urls: [url.value] })
});
const result = await response.json();
if (result.success) {
console.log('Submitted to IndexNow:', url.value);
}
} catch (error) {
console.error('IndexNow submission failed:', error);
}
});
}
```
The composable uses Vue's `watch` function to observe the `shouldSubmit` ref. When it becomes true, the composable triggers an async fetch to your IndexNow API endpoint. This pattern integrates with Vue's reactivity, so any state change that sets `shouldSubmit` to true will automatically trigger a submission.
In your Vue components, you can use the composable like this with reactive refs. Import the composable, create refs for your URL and submission flag, and the composable will handle the rest. For example, in a blog editor component, you might set `shouldSubmit` to true after successfully saving a post to your database.
The beauty of this approach is that it's completely reactive and composable. You can combine it with other Vue composables like `useFetch` or `useAsyncData` to create a complete content publishing workflow that automatically notifies search engines when new content goes live.
### Nuxt Implementation
Nuxt provides a server API that makes IndexNow integration straightforward. With Nuxt's file-based routing for API endpoints, you can create server routes that handle IndexNow submissions with minimal configuration.
Create a server API endpoint using Nuxt's conventions:
```typescript title="server/api/indexnow.post.ts"
export default defineEventHandler(async (event) => {
const { urls } = await readBody(event);
const result = await submitToIndexNow(
urls,
process.env.INDEXNOW_API_KEY!,
'yourdomain.com'
);
return result;
});
```
Nuxt's `defineEventHandler` wraps your endpoint logic and provides type-safe access to the request through `readBody`. The `.post.ts` suffix in the filename automatically restricts this endpoint to POST requests only, eliminating the need for manual method checking.
The endpoint imports your IndexNow utility function and environment variables are automatically available through `process.env`. Since this runs on the server, you have direct access to all Node.js APIs and can safely use sensitive API keys without exposing them to the client.
From the client side, you can call this endpoint using Nuxt's `$fetch` utility or the standard Fetch API. The integration works with Nuxt's server-side rendering, meaning you can trigger IndexNow submissions during SSR, static generation, or client-side navigation.
For automatic submissions during build, you can create a Nuxt module that hooks into the build process. This module would read your sitemap after generation and submit all URLs to IndexNow, ensuring every page gets indexed whenever you deploy.
### Vanilla JavaScript Implementation
For websites built without frameworks or static sites that just need JavaScript, you can implement IndexNow with a simple standalone function. This approach works for any website that can make HTTP requests, whether it's a traditional multi-page application, a static site, or even a WordPress theme.
Here's a complete implementation that works anywhere:
```javascript title="JavaScript"
async function submitToIndexNow(urls) {
const apiKey = 'your-api-key-here';
const host = 'yourdomain.com';
const payload = {
host: host,
key: apiKey,
urlList: urls
};
try {
const response = await fetch('https://api.indexnow.org/indexnow', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(payload)
});
return await response.json();
} catch (error) {
console.error('IndexNow submission failed:', error);
}
}
// Usage
submitToIndexNow(['https://yourdomain.com/new-page']);
```
This vanilla implementation handles everything in a single function. It constructs the payload according to IndexNow specifications, makes the POST request directly to the IndexNow API endpoint, and handles both success and error cases.
The function is completely self-contained with no dependencies, making it perfect for adding to existing sites without any build process. You can include it in a script tag, bundle it with your existing JavaScript, or even inline it directly in your HTML.
The key difference from the framework approaches is that you're hitting the IndexNow API directly rather than going through your own backend endpoint. This means your API key is exposed in the client code, which is fine for IndexNow since the key is already public in your verification file. However, if you want to keep submission logic server-side, you could modify this to call your own API endpoint instead, similar to the framework examples.
You can trigger this function from anywhere, like after a form submission, when content loads dynamically, or even attach it to click events on publish buttons in a content management system.
### SvelteKit Implementation
SvelteKit's server-side routing makes IndexNow integration remarkably straightforward with its file-based API routes. The framework's convention-over-configuration approach means you can create endpoints with minimal boilerplate, and the built-in request handling gives you everything needed to process IndexNow submissions securely on the server.
Create a server route for handling IndexNow submissions:
```typescript title="src/routes/api/indexnow/+server.ts"
export const POST: RequestHandler = async ({ request }) => {
try {
const { urls } = await request.json();
if (!urls || !Array.isArray(urls)) {
return json(
{ success: false, error: 'Invalid request body' },
{ status: 400 }
);
}
const result = await submitToIndexNow(urls, INDEXNOW_API_KEY, 'yourdomain.com');
return json(result, { status: result.success ? 200 : 500 });
} catch (error) {
return json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
};
```
The `+server.ts` naming convention tells SvelteKit this is a server-only route that won't be included in client bundles. Environment variables use the `$env/static/private` module, ensuring your API key stays server-side. For client-side usage, create a Svelte store that manages submission state, and the reactive system automatically updates your UI based on submission status.
### Angular Implementation
Angular's dependency injection system and service-based architecture make it ideal for creating reusable IndexNow integration. Create a dedicated service for handling submissions, inject it wherever needed, and maintain clean, testable code throughout your application.
```typescript title="src/app/services/indexnow.service.ts"
interface IndexNowResponse {
success: boolean;
statusCode?: number;
message?: string;
error?: string;
}
@Injectable({
providedIn: 'root'
})
export class IndexNowService {
private http = inject(HttpClient);
private apiUrl = '/api/indexnow';
submitUrls(urls: string[]): Observable {
const headers = new HttpHeaders({
'Content-Type': 'application/json'
});
return this.http.post(
this.apiUrl,
{ urls },
{ headers }
).pipe(
tap(result => {
if (result.success) {
console.log('IndexNow submission successful:', result.message);
}
}),
catchError(error => {
console.error('IndexNow submission failed:', error);
return throwError(() => new Error(error.message || 'Submission failed'));
})
);
}
submitSingleUrl(url: string): Observable {
return this.submitUrls([url]);
}
}
```
The service uses Angular's `inject()` function for dependency injection, and `providedIn: 'root'` makes it a singleton available throughout your application. RxJS operators like `tap` and `catchError` provide clean error handling. Inject the service in components and call its methods when publishing content, with observables handling async operations.
### Laravel Implementation
Laravel's elegant routing and controller system make IndexNow integration straightforward with its expressive syntax. The framework's built-in HTTP client and response helpers provide everything needed to create clean, maintainable IndexNow endpoints that feel natural within the Laravel ecosystem.
Laravel's service container and dependency injection make it easy to create reusable IndexNow services that can be injected anywhere in your application. Whether you're building a REST API, a traditional web app, or a headless CMS, Laravel's flexibility ensures IndexNow integration fits into your architecture.
Create a controller to handle IndexNow submissions:
```php title="app/Http/Controllers/IndexNowController.php"
all(), [
'urls' => 'required|array',
'urls.*' => 'required|url'
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'error' => 'Invalid request body'
], 400);
}
$urls = $request->input('urls');
$apiKey = config('services.indexnow.api_key');
$host = config('app.url');
$payload = [
'host' => parse_url($host, PHP_URL_HOST),
'key' => $apiKey,
'urlList' => $urls
];
try {
$response = Http::withHeaders([
'Content-Type' => 'application/json; charset=utf-8'
])->post('https://api.indexnow.org/indexnow', $payload);
if ($response->successful() || $response->status() === 202) {
return response()->json([
'success' => true,
'statusCode' => $response->status(),
'message' => "Successfully submitted " . count($urls) . " URL(s) to IndexNow"
]);
}
return response()->json([
'success' => false,
'statusCode' => $response->status(),
'error' => 'IndexNow submission failed'
], 500);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage()
], 500);
}
}
}
```
Register the route in your routes file:
```php title="routes/api.php"
use App\Http\Controllers\IndexNowController;
Route::post('/indexnow', [IndexNowController::class, 'submit']);
```
Add your IndexNow API key to the config. Laravel's configuration system keeps sensitive data in environment variables:
```php title="config/services.php"
return [
// Other services...
'indexnow' => [
'api_key' => env('INDEXNOW_API_KEY'),
],
];
```
Laravel's validator ensures URLs are properly formatted before submission. The built-in HTTP client handles the IndexNow API call with clean, readable syntax. Response helpers like `response()->json()` make it easy to return properly formatted JSON responses with appropriate status codes.
### Ruby on Rails Implementation
Ruby on Rails brings convention over configuration to IndexNow integration, making it simple to create clean, RESTful endpoints. The framework's emphasis on developer happiness means you can build IndexNow functionality with minimal boilerplate code.
Rails' built-in HTTP client and strong parameters make handling IndexNow submissions secure and straightforward. The framework's MVC architecture naturally separates concerns, allowing you to organize your IndexNow logic in a way that's maintainable and testable.
Create a controller to handle IndexNow submissions:
```ruby title="app/controllers/index_now_controller.rb"
class IndexNowController < ApplicationController
skip_before_action :verify_authenticity_token
def submit
unless params[:urls].is_a?(Array)
render json: {
success: false,
error: 'Invalid request body. Expected urls array'
}, status: :bad_request
return
end
urls = params[:urls]
api_key = ENV['INDEXNOW_API_KEY']
host = request.host
payload = {
host: host,
key: api_key,
urlList: urls
}
begin
response = HTTP.post('https://api.indexnow.org/indexnow',
json: payload,
headers: { 'Content-Type' => 'application/json; charset=utf-8' }
)
if response.status.success? || response.code == 202
render json: {
success: true,
statusCode: response.code,
message: "Successfully submitted #{urls.length} URL(s) to IndexNow"
}
else
render json: {
success: false,
statusCode: response.code,
error: 'IndexNow submission failed'
}, status: :internal_server_error
end
rescue StandardError => e
render json: {
success: false,
error: e.message
}, status: :internal_server_error
end
end
end
```
Add the route to your routes file:
```ruby title="config/routes.rb"
Rails.application.routes.draw do
post '/api/indexnow', to: 'index_now#submit'
end
```
Rails' strong parameters and built-in request/response handling make the controller code clean and secure. The `skip_before_action :verify_authenticity_token` is necessary for API endpoints that accept JSON from external sources. For production use, you'd want to add proper authentication or rate limiting middleware.
Add the http gem to your Gemfile for making HTTP requests, or use Rails' built-in `Net::HTTP` if you prefer no additional dependencies. The controller automatically handles JSON parsing from the request body and renders JSON responses with appropriate HTTP status codes.
### Django Implementation
Django's clean, pragmatic design makes IndexNow integration straightforward with its view system and JsonResponse class. The framework's "batteries included" philosophy means you have everything needed for handling HTTP requests and JSON responses built right in.
Django views can be either function-based or class-based, but for API endpoints like IndexNow, function-based views are often simpler and more direct. The framework's URL routing system makes it easy to map specific paths to view functions, and the JsonResponse class handles all the details of creating properly formatted JSON responses with correct content-type headers.
**Before implementing this endpoint**, make sure you've generated your IndexNow API key using `openssl rand -hex 16` as described earlier in this guide. You'll also need to create the verification file at `public/YOUR_API_KEY.txt` containing your key.
Create a Django view to handle IndexNow submissions:
```python title="myapp/views.py"
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
import requests
import json
import os
@csrf_exempt
@require_http_methods(["POST"])
def indexnow_submit(request):
try:
data = json.loads(request.body)
urls = data.get('urls', [])
if not urls or not isinstance(urls, list):
return JsonResponse({
'success': False,
'error': 'Invalid request body. Expected urls array'
}, status=400)
api_key = os.environ.get('INDEXNOW_API_KEY')
if not api_key:
return JsonResponse({
'success': False,
'error': 'INDEXNOW_API_KEY not configured'
}, status=500)
host = request.get_host()
payload = {
'host': host,
'key': api_key,
'urlList': urls
}
response = requests.post(
'https://api.indexnow.org/indexnow',
json=payload,
headers={'Content-Type': 'application/json; charset=utf-8'}
)
if response.status_code in [200, 202]:
return JsonResponse({
'success': True,
'statusCode': response.status_code,
'message': f'Successfully submitted {len(urls)} URL(s) to IndexNow'
})
return JsonResponse({
'success': False,
'statusCode': response.status_code,
'error': 'IndexNow submission failed'
}, status=500)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Invalid JSON'
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
```
Register the URL pattern in your Django URLconf:
```python title="myapp/urls.py"
from django.urls import path
from . import views
urlpatterns = [
path('api/indexnow/', views.indexnow_submit, name='indexnow_submit'),
]
```
The `@csrf_exempt` decorator is necessary for API endpoints that accept JSON from external sources, as Django's CSRF protection expects form-encoded data. The `@require_http_methods` decorator ensures only POST requests are accepted. Django's `request.get_host()` automatically extracts the domain from the incoming request, and JsonResponse handles serialization with proper headers.
### Flask Implementation
Flask's minimalist design and flexibility make it perfect for creating lightweight IndexNow endpoints. The framework's simplicity means you can create a working API endpoint in just a few lines of code, yet it can handle production workloads.
Flask's routing decorator syntax is clean and intuitive, making it obvious which functions handle which URLs. The `jsonify` function automatically serializes Python dictionaries to JSON with the correct content-type headers, and `request.get_json()` handles parsing incoming JSON payloads with proper error handling.
**Before implementing this endpoint**, make sure you've generated your IndexNow API key using `openssl rand -hex 16` as described earlier in this guide. You'll also need to create the verification file at `public/YOUR_API_KEY.txt` containing your key.
Create a Flask route for IndexNow submissions:
```python title="app.py"
from flask import Flask, request, jsonify
import requests
import os
app = Flask(__name__)
@app.route('/api/indexnow', methods=['POST'])
def indexnow_submit():
try:
data = request.get_json()
if not data:
return jsonify({
'success': False,
'error': 'Invalid JSON'
}), 400
urls = data.get('urls', [])
if not urls or not isinstance(urls, list):
return jsonify({
'success': False,
'error': 'Invalid request body. Expected urls array'
}), 400
api_key = os.environ.get('INDEXNOW_API_KEY')
if not api_key:
return jsonify({
'success': False,
'error': 'INDEXNOW_API_KEY not configured'
}), 500
host = request.host
payload = {
'host': host,
'key': api_key,
'urlList': urls
}
response = requests.post(
'https://api.indexnow.org/indexnow',
json=payload,
headers={'Content-Type': 'application/json; charset=utf-8'}
)
if response.status_code in [200, 202]:
return jsonify({
'success': True,
'statusCode': response.status_code,
'message': f'Successfully submitted {len(urls)} URL(s) to IndexNow'
}), 200
return jsonify({
'success': False,
'statusCode': response.status_code,
'error': 'IndexNow submission failed'
}), 500
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
if __name__ == '__main__':
app.run(debug=True)
```
Flask's tuple return syntax allows you to return both the response body and status code in one clean line. The `request.get_json()` method automatically handles JSON parsing and returns None if the content-type is wrong or the JSON is malformed, making error handling straightforward. This pattern works whether you're deploying to a traditional server, a containerized environment, or serverless platforms.
### FastAPI Implementation
FastAPI is built for Python development with automatic API documentation, data validation via Pydantic, and async support out of the box. The framework's use of Python type hints means you get automatic request validation, serialization, and interactive API docs without writing extra code.
FastAPI's dependency injection system and Pydantic models create self-documenting, type-safe APIs that catch errors at development time rather than runtime. The framework automatically generates OpenAPI (Swagger) documentation, making your IndexNow endpoint discoverable and testable through a web interface.
**Before implementing this endpoint**, make sure you've generated your IndexNow API key using `openssl rand -hex 16` as described earlier in this guide. You'll also need to create the verification file at `public/YOUR_API_KEY.txt` containing your key.
Create a Pydantic model and FastAPI endpoint:
```python title="main.py"
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List
import httpx
import os
app = FastAPI()
class IndexNowRequest(BaseModel):
urls: List[str]
class IndexNowResponse(BaseModel):
success: bool
statusCode: int | None = None
message: str | None = None
error: str | None = None
@app.post("/api/indexnow", response_model=IndexNowResponse)
async def indexnow_submit(request: IndexNowRequest):
try:
if not request.urls:
raise HTTPException(
status_code=400,
detail="Invalid request body. Expected urls array"
)
api_key = os.environ.get('INDEXNOW_API_KEY')
if not api_key:
raise HTTPException(
status_code=500,
detail="INDEXNOW_API_KEY not configured"
)
host = 'yourdomain.com'
payload = {
'host': host,
'key': api_key,
'urlList': request.urls
}
async with httpx.AsyncClient() as client:
response = await client.post(
'https://api.indexnow.org/indexnow',
json=payload,
headers={'Content-Type': 'application/json; charset=utf-8'}
)
if response.status_code in [200, 202]:
return IndexNowResponse(
success=True,
statusCode=response.status_code,
message=f'Successfully submitted {len(request.urls)} URL(s) to IndexNow'
)
return IndexNowResponse(
success=False,
statusCode=response.status_code,
error='IndexNow submission failed'
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
```
FastAPI's automatic validation means if someone sends invalid data, they get a detailed error response showing exactly what went wrong. The `response_model` parameter ensures your response always matches the expected schema, and the async/await syntax enables high-performance concurrent request handling. Access your auto-generated API docs at `/docs` to test the endpoint interactively.
### Express.js Implementation
Express is the de facto standard for Node.js web applications, powering countless APIs with its minimal, flexible approach. The framework's middleware system and routing are battle-tested and understood by millions of developers, making it an excellent choice for adding IndexNow to existing Node.js applications.
Express middleware functions have access to the request and response objects, plus a `next` function to pass control to the next middleware. This pattern makes it easy to add logging, authentication, or data validation before your route handlers execute. The `express.json()` middleware automatically parses incoming JSON payloads.
**Before implementing this endpoint**, make sure you've generated your IndexNow API key using `openssl rand -hex 16` as described earlier in this guide. You'll also need to create the verification file at `public/YOUR_API_KEY.txt` containing your key.
Create an Express route for IndexNow:
```javascript title="server.js"
const express = require('express');
const fetch = require('node-fetch');
const app = express();
app.use(express.json());
app.post('/api/indexnow', async (req, res) => {
try {
const { urls } = req.body;
if (!urls || !Array.isArray(urls)) {
return res.status(400).json({
success: false,
error: 'Invalid request body. Expected urls array'
});
}
const apiKey = process.env.INDEXNOW_API_KEY;
if (!apiKey) {
return res.status(500).json({
success: false,
error: 'INDEXNOW_API_KEY not configured'
});
}
const host = req.get('host');
const payload = {
host: host,
key: apiKey,
urlList: urls
};
const response = await fetch('https://api.indexnow.org/indexnow', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(payload)
});
if (response.status === 200 || response.status === 202) {
return res.json({
success: true,
statusCode: response.status,
message: `Successfully submitted ${urls.length} URL(s) to IndexNow`
});
}
return res.status(500).json({
success: false,
statusCode: response.status,
error: 'IndexNow submission failed'
});
} catch (error) {
return res.status(500).json({
success: false,
error: error.message
});
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
```
Express's `req.get('host')` extracts the domain from request headers, and the async/await syntax keeps the code clean despite the asynchronous nature of HTTP requests. This pattern integrates with existing Express applications, and you can add authentication middleware or rate limiting by simply adding more middleware functions before this route handler.
### Remix Implementation
Remix brings a fresh approach to React frameworks by embracing web fundamentals and progressive enhancement. Its action and loader pattern provides type-safe data flow between server and client, making it natural to handle IndexNow submissions as part of your form processing workflow.
Remix actions run only on the server, giving you secure access to environment variables and external APIs without exposing sensitive data to the client. The framework's convention of colocating loaders, actions, and components in a single file keeps related code together, improving maintainability and developer experience.
**Before implementing this endpoint**, make sure you've generated your IndexNow API key using `openssl rand -hex 16` as described earlier in this guide. You'll also need to create the verification file at `public/YOUR_API_KEY.txt` containing your key.
Create a Remix action for IndexNow:
```typescript title="app/routes/api.indexnow.ts"
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
return json({ error: "Method not allowed" }, { status: 405 });
}
try {
const { urls } = await request.json();
if (!urls || !Array.isArray(urls)) {
return json({
success: false,
error: 'Invalid request body. Expected urls array'
}, { status: 400 });
}
const apiKey = process.env.INDEXNOW_API_KEY;
if (!apiKey) {
return json({
success: false,
error: 'INDEXNOW_API_KEY not configured'
}, { status: 500 });
}
const host = new URL(request.url).host;
const payload = {
host: host,
key: apiKey,
urlList: urls
};
const response = await fetch('https://api.indexnow.org/indexnow', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(payload)
});
if (response.status === 200 || response.status === 202) {
return json({
success: true,
statusCode: response.status,
message: `Successfully submitted ${urls.length} URL(s) to IndexNow`
});
}
return json({
success: false,
statusCode: response.status,
error: 'IndexNow submission failed'
}, { status: 500 });
} catch (error) {
return json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 });
}
}
```
Remix's `json` helper function handles serialization and sets appropriate headers automatically. The framework's file-based routing means this file at `app/routes/api.indexnow.ts` automatically creates an endpoint at `/api/indexnow`. You can call this action from any Remix form using `