How I Built an SEO/AEO Blog Engine

@harsehaj
INGLÉS24 ago 2026
281K
777
49
21
2.8K

TL;DR

This article details the creation of blogEO, an engineering project that automates SEO audits and content generation while tracking AI engine visibility to drive significant growth in search traffic.

i proposed and executed an engineering project end-to-end this summer as a growth engineering intern at @browserbase: blogEO, an engine that audits, rewrites, and generates SEO/AEO-tailored blogs on a weekly basis, and then measures if any of it worked.

harsehaj ⋆˙⟡ - inline image

tl;dr

  • the browserbase bloghad no SEO/AEO strategy or tooling → 53% of our search traffic came from 5 posts, ~74 posts had empty SEO fields (title + description), and only 17/77 posts had ever been cited by an AI engine.
  • i built blogEO, a weekly engine that 1) audited existing blog posts and suggested fixes, 2) generated new blogs to fill real search demand, and 3) tracked AEO metrics.
  • importantly, the agent (bb) has no write tool. it only scores, drafts, and posts buttons into Slack. a server-side handler does the writing after a human clicks approve.
  • each approved audit edit takes a before-snapshot along with the blog-wide numbers for the same dates. the snapshot gets re-read at 28 and 56 days.
  • as of august 21, 2026, blogEO has generated 18 blog posts and 50+ edits.

a blog without measurement

developers read blogs. they’re a source of truth, and llms pull from them to build confidence about how to write code. if we’re not showing up, then we aren’t capturing the kind of traffic that compounds passively while asleep.

our blog was manual, unmaintained, and unmeasured. i knew it well - i had written dozens of browserbase blogs myself since october 2025 after all. the older posts referenced outdated product details, underperforming posts were untouched because nothing was monitoring rankings, and all of these analytics were spread across 5 places that nobody signed into.

when i pulled this data at the start of the summer, i realized this was an issue that needed a lot more than some scrubbing. a handful of posts on our blog carried the bulk of our numbers, while the rest were inert.

harsehaj ⋆˙⟡ - inline image

ranking but no clicking

the more frustrating part was the fact that we were indeed ranking. our informational blogs were ranking at positions 6-13, but weren’t getting any clicks. one post had 329,454 impressions at an average position of 9.4, but produced 857 clicks. that’s a 0.26% click rate…

google was showing us to 300k+ people, and basically none of them came through.

btw, position refers to where a link sits on a google search result page. position 1 means it’s the first link, on the top.

harsehaj ⋆˙⟡ - inline image

invisible to LLMs

i also wanted to know whether or not answer engines were quoting us at all. the AEO strategy would follow (i explained AEO here). out of all of our 77 blog posts published before june, only 17 had ever been cited. even that was skewed, with 3 posts doing most of the heavy lifting.

harsehaj ⋆˙⟡ - inline image

why no solution?

each of these issues was manually discoverable. it was a pretty rough discovery process, though - it meant someone had to bridge together 5 different systems (our CMS Sanity, Google Search Console, SEMrush, PostHog, and last week’s numbers) that didn’t integrate, and then redo that connection every week so the data wasn’t stale.

harsehaj ⋆˙⟡ - inline image

what i built: blogEO

i connected all of these pieces + some more. blogEO is an audit, generator, and AEO tracker. all three run automatically on a weekly cadence through our internal agent, bb, into one Slack channel. all three functionalities can also be called on demand through Slack.

  1. the blog audit (fixes what exists) → it scores every published post based on opportunity and decay, drafts surgical fixes for the posts with highest opportunity, and posts each edit as Approve/Edit/Skip cards in a Slack thread.
  2. the blog generator (creates what’s missing) → it goes through live search data for demand that we don’t match with blog posts, writes a draft with code snippets traced to our docs, and gates it on 4 automated checks.
  3. AEO tracking → sees whether ChatGPT, Claude and Google’s AI Overviews quote us and determines why a post isn’t cited.

one core design decision

bb can look, think, and suggest, but i didn’t give it any write tool. it can’t change the website. the code that can run on the server when a human clicks a button (approve).

this way, the worst a bad run could do was make a bad suggestion in Slack.

this design also made the code correctly malleable. the agent’s judgement lives in a skill file (cheap to change), while the write path is code with tests, and it revalidates everything claimed by the agent.

harsehaj ⋆˙⟡ - inline image

picking what to fix (audit)

the audit is one run: it scores every published post, drafts fixes for the highest-opportunity ones, and then posts the edits to Slack.

then the run ends.

the button clicks are handled later by a separate handler, and independently from the agent’s session.

harsehaj ⋆˙⟡ - inline image

the audit steps:

  1. 5 batched data pulls from Sanity, GSC, SEMrush, PostHog & last week’s run (done through bb via tools).
  2. hygiene scan across every post for broken links, dead images/embeds, positioning drift, missing SEO fields, and typos (done through bb & in memory).
  3. score and rank the opportunity for each post, then verify factual claims in the top ~15 flagged posts only (done through bb).
  4. draft surgical edits and mark two safe edit types for auto-publish (bb proposes and server validates)
  5. persist each suggestion to KV and then a run snapshot to Postgres (done through server).
  6. post the audit summary + threaded card per flagged post/edit (done through server).

scoring by opportunity

the audit ranks by headroom, not age. i did this because i wanted to act based on the answer to: “if i improved this blog post, how many more visitors would we actually get?”

to do this, each post is scored 3 ways against the previous 28 days. the biggest of these 3 estimates “wins” and thus decides what kind of fix to write.

  1. recover → these are clicks lost vs. the previous window. therefore, a regression to fix.
  2. ctr → the page-one clicks that our blog post didn’t get, compared to the expected CTR its position should’ve earned. therefore, a title/description fix.
  3. rank → the # of clicks a page-two post would gain by landing on page-one instead. therefore, a content push.

the CTR lever needs a model of how many clicks x position should earn. that’s an organic click-through curve.

typescript
1export function expectedCtr(position: number): number {
2 if (position <= 0) return 0;
3 if (position <= 1) return 0.28;
4 if (position <= 2) return 0.15;
5 if (position <= 3) return 0.10;
6 if (position <= 5) return 0.06;
7 if (position <= 7) return 0.04;
8 if (position <= 10) return 0.025;
9 return 0.01;
10}
harsehaj ⋆˙⟡ - inline image

if i put this post through the CTR lever, i get a number for "opportunity” that puts it at the top of the audit queue.

measurement

value

impressions

329,454

clicks

857

actual CTR

0.260%

expectedCtr(9.4)

2.500%

gap x impressions

0.02240 x 329,454

estimated recoverable clicks

≈ 7,380

~7k clicks of headroom. the value of “opportunity” > refreshing because “this post is old.”

the limitation here is that the 2.5% benchmark uses average position, which GSC blends across every query that a page appears for. it over-counts the real headroom - so it’s a soft absolute signal, but valuable for ordering in this audit queue.

two guardrails for ranking on the edit queue:

  1. if impressions are low to begin with, a low ctr won’t score as high opportunity. no edit can fix the fact that “nobody is searching for this.” these kinds of posts are just marked as low-visibility and dropped from the ranking.
  2. a real click loss > any estimates. a post that genuinely lost traffic will surface regardless of where it is in the opportunity ranking. this drop is determined if there’s both a material absolute drop in clicks + a real proportional one.

rationing checks over tokenmaxxing

the cheap checks run on all published posts (age, empty SEO fields, dead links, traffic drops, and a word search for old positioning).

the more expensive check (comparing posts against our docs) costs a lot of tokens, so it’s only run on the top 15 flagged posts + 3 more posts as a buffer, so the posts with low impressions aren’t left unverified forever.

these flags are also not from memory. the real page must be loaded; only direct contradictions are flagged, and the source is quoted

harsehaj ⋆˙⟡ - inline image

the edits are intentionally small

a suggestion can only change the SEO title/description, or swap one exact phrase/link. if a fix can’t be one clean swap, then the card shows up only with Edit and Skip. a human has to write in the edit in this case.

for public-facing prose, i believe this to be the right place to draw the line.

also, dead links with a known replacement + filling an empty SEO field are two classes of edits that are auto-applied, so they bypass human approval completely.

harsehaj ⋆˙⟡ - inline image

measuring whether it worked

a GSC snapshot from 28 days prior is taken once a real edit goes live. data on how the whole blog was doing over those same dates is also captured, because without this piece, a 20% improvement would tell me nothing since Google may have lifted everything.

harsehaj ⋆˙⟡ - inline image
harsehaj ⋆˙⟡ - inline image

writing new posts (generator)

the audit was actually v0 of my project. it set up the groundwork for my real goal, which was to increase SEO/AEO traffic. an audit alone can’t fix a blog where 72/77 posts get no traffic - the traffic isn’t even there.

the richest source of traffic is near-misses, where Google already shows us 150+ times, but we’re at positions 5-20 without a dedicated post. one of my runs on GSC found 120 of these near-misses, including “what is a captcha solver?” at 19.7k impressions and position 10.6 without us even having a specific blog for this query.

one generator request produces a publish-ready draft, unlisted, and waiting for human approval through Slack. same as the audit, bb will never publish the blog.

harsehaj ⋆˙⟡ - inline image

the generator steps:

  1. pick a topic from GSC data or take a provided one (via seo-strategy skill).
  2. write the draft with all code snippets pulled exactly from our docs (via browserbase-writing skill).
  3. check draft against the writing checklist (via bb)
  4. gate draft on 4 automated checks before saving anything (via code)
  5. create the unlisted draft (via code)
  6. post the Slack card with Approve and Discard buttons (via code)
  7. clicking Approve publishes it unlisted and starts measuring (via server)

3 skills

i separated 3 skills for this flow in order for it to be maintainable, since 2 copies of a rule become 2 different rules and things get messy. if the pipeline file ever starts shaping what good writing is, that means it’s in the wrong file.

harsehaj ⋆˙⟡ - inline image

the automated gate

before reaching Sanity, a draft has to pass 4 automated checks. this way, a failing draft doesn’t end up as junk to clean up.

  1. strategy → ensures no banned terms and the post maps to a real content cluster + persona
  2. structure → ensures a post opens with a labelled TL;DR section + a few other nits.
  3. code provenance → ensures every snippet has the docs URL it came from. no invented code.
  4. cannibalization → ensures that none of the target queries are already covered by one of our own posts. writing a 2nd post splits our own ranking, so bb is forbidden from retrying/rephrasing. instead, the post that owns this query is identified and handed to the audit as an edit.
typescript
1input.body.forEach((block, i) => {
2 if (block.type !== 'code') return;
3
4 if (!block.docsUrl) {
5 failures.push(`code block ${i} (${block.language}) has no docsUrl (source of truth)`);
6 } else if (!/^https?:\/\//i.test(block.docsUrl)) {
7 failures.push(`code block ${i} docsUrl is not an http(s) URL: "${block.docsUrl}"`);
8 }
9
10 // advisory only, won't block the draft
11 if (!block.version) advisories.push(`code block ${i} has no version pin`);
12});

heavily gating prose and structure means hardcoding taste, and that’s incredibly unmaintainable code. whether that writing is any good is the checklist’s job, which is done by bb before gating.

bb has 2 attempts to redraft on failure and then stops with no draft produced.

measuring a post that starts at zero

the edits had a “before.” a new post doesn’t, so it’s measured at a growth curve instead, with the same structure of 28 & 56-day readings, which include the blog-wide numbers for the same dates. on approve, the post is published, enrolled, then recorded. this way, a recording failure won’t block enrollment → enrollment failure won’t block publishing.

harsehaj ⋆˙⟡ - inline image

whether LLMs quote us

GSC tells me if Google sends people, but i still had 0 data on referrals/quotes from ChatGPT, Claude or AI Overviews. the first thing i did was split “AI referral” into 3 segments:

  • does an engine crawl the page?
  • does it cite us in an answer?
  • does anyone click through?

i shipped cite through a parser i wrote for the SEMrush AI visibility export, which is unfortunately export-only with no API. this maps each row back to a post slug.

click already worked, because Google folds AI Overviews into normal search performance, and the other engines classify by referrer domain.

interestingly, the posts that win in Google and the ones that get cited by AI aren’t the same. our strongest search performers were only cited twice by AI.

i ended up deferring the crawl layer because it needed a rescoping to access Vercel log drains behind SSO. i wrote up next steps in a hand-off document for my team.

how it all flows

put together, bb reads and reasons, one server writes, and they both meet a person in Slack.

harsehaj ⋆˙⟡ - inline image

the measurement lives in a schema across 5 tables:

  • blog_audit_runs → scored snapshot of each post
  • blog_audit_edits → what changed, before snapshot, +28d/+56d readings & cooldown
  • blog_generated_posts → quality-gate report & checkpoints
  • blog_aeo_signals → AI citation counts
  • blog_aeo_domain → domain-wide AI visibility

three messages per week

i firmly believe new tools should integrate with existing workflows, not give people another website or login. fragmentation here is exactly what that looks like when it goes wrong. BlogEO’s whole interface is a Slack channel with scheduled runs:

  • new blog draft → 9am PT Monday → the week’s generated post with Approve card + a note on what was written and why.
  • blog audit → 9am PT Tuesday → a ranked thread of existing posts, hygiene findings, and Approve/Edit/Skip cards for each suggested edit.
  • performance & tuning → 9am PT Friday → a report including growth curves, edit-measurement state, AEO diagnosis per post, and suggested measurement edits.

a card begins as a suggestion and ends as a link to a live post. the in-between states took the most refining to get right. the suggestion locks as soon as someone clicks, so two people can’t both publish. the publish will also refuse if someone has an unsaved draft of that post on Sanity, because that draft would be from an older version and would undo the edit when someone saves it.

harsehaj ⋆˙⟡ - inline image
harsehaj ⋆˙⟡ - inline image

outside of the weekly cadence, the same pieces are still callable:

  • @bb what’s the SEO performance of this blog: ___”
  • @bb draft a blog explaining how to browse the web securely.”
  • @bb draft a blog”
  • @bb give me a title for this blog: ____”
  • @bb run the blog audit”

our internal agent now also has a completely new toolkit.

btw, a requested topic isn’t a pre-approved topic - it still gets classified, checked against guardrails, and run through the cannibalization check. if someone’s idea fails, bb will stop and ask follow-up questions.

the results so far

18 posts have been generated and published, ~74 posts backfilled with SEO titles/descriptions, 53+ older posts edited since July, and 6 weeks of BlogEO running on its own cadence.

the most rewarding part has been seeing the team continue to use the tool and generate/approve more posts after i handed it off and completed my internship.

my blog initiatives at browserbase (both writing and this project) led to search impressions growing 5.8x, page-one queries growing 9.8x, and our average blog position going from 10.9 → 6.6.

a few limitations include the opportunity estimate being soft, crawl being deferred, rationing the fact-checking, and AI citation data being a manual CSV export for now.

what i learned

overall, i learned a ton from executing my first engineering project end to end. this was just one of the many things i worked on over the summer - i’ll be reflecting and recapping the rest soon.

some quick lessons i’ll expand on in a later post:

  • i learned how to scope and rescope an engineering project with extensive planning.
  • it’s important to be meticulous about measurement. controls are important!
  • the ux is the product, so it has to exist where work is already happening. nobody wants another login.

special shoutout also to @JaySahnan for being a great mentor, poking holes at my thinking, putting me onto the best chai in san francisco, and reviewing all of my prs …

onto the next! 🔨

harsehaj 💌

Recrear en YouMind

Turn one viral article into a full content workflow

Collect the source, decode the pattern, create assets, draft the story, and distribute from one AI workspace.

Explore YouMind
Para creadores

Convierte tu Markdown en un artículo de 𝕏 impecable

Cuando publicas tus propios textos largos, dar formato en 𝕏 a imágenes, tablas y bloques de código es un fastidio. YouMind convierte un borrador completo en Markdown en un artículo de 𝕏 impecable y listo para publicar.

Prueba Markdown a 𝕏

Más patrones por descifrar

Artículos virales recientes

Explorar más artículos virales