I searched for my name and my site wasn't there. This is what I changed over three days so Google and Bing can find it, and so ChatGPT, Claude and Perplexity can read it, understand it and cite it.
A few days ago I searched the web for "Rubén Rangel". The top result was a Venezuelan cyclist on Wikipedia. My site, with twelve articles published in two languages, didn't show up even when I searched for the domain.
The problem isn't just Google anymore. More and more people ask a chat instead of a search engine, and those chats answer with whatever their crawlers managed to read and understand. That's called GEO (Generative Engine Optimization): optimizing so a generative engine finds you and cites you, the same way SEO optimizes for a spot in a list of results.
I set a goal with two halves: the site should rank in search engines, and AI chats should find, understand and cite Rubén Rangel. This article covers what I did, in what order, and what I still don't know.
The starting point
The blog is a Laravel app with the posts as Markdown files in the repo and no admin panel (I wrote about it here). It worked fine for people. For machines, not so much. The first audit found this:
- The blog, the agent demo and the insights page declared the home page URL as their canonical. They were telling Google they were copies of another page.
- The site answered on both
www.rubenrangel.organdrubenrangel.org, two versions of every URL. - The sitemap listed the root, which redirects, and every URL had today's date as its
lastmod. - A test post was indexed.
- No page had an Open Graph image, and the home page had no structured data.
The second pass, this time thinking about AI, found more: two <h1> tags per post, no factual sentence saying who I am and where I work, and nothing designed for an agent to read the content without fighting the HTML.
Part 1: what a search engine needs to see only once
The first job was to stop sending mixed signals:
- A self-referencing canonical on every page, with
hreflangfor/enand/esplus anx-default. URLs are built fromAPP_URL, so no other host can sneak in. - A 301 redirect from
wwwto the main domain, in Cloudflare. noindexon blog searches and on the insights page. Pagination keeps?pagein the canonical, so page 2 doesn't declare itself a copy of page 1.- A single
<h1>per post, with a "by Rubén Rangel" byline linked withrel="author". - Titles of 60 characters at most. Bing flagged "Title too long" on 20 URLs, one of them 125 characters long. I added an optional per-language
seo_titleto each post'smeta.json: the<title>uses the short version and appends " | Rubén Rangel" only if it fits, while the<h1>,og:titleandheadlinekeep the full title. - Meta descriptions of 155 characters at most, with new copy on the home page and automatic trimming on posts. The home page and 18 posts were too long.
- An Open Graph image per post: a 1200×630 card generated with GD at
/og/{lang}/{slug}and cached on disk. When I deployed it, every card returned 500 in production because the server didn't have the GD extension. It's now declared incomposer.json, and if a card fails, the portrait is used instead.
The lastmod that lied
The most interesting case was the sitemap. Every URL had today's date because the read counter touched updated_at: every visit to a post marked it as modified. To a search engine, that meant all twelve articles changed every day, and a signal that always changes tells you nothing.
The code fix was small:
// A read is not a content change: keep updated_at (dateModified, sitemap lastmod) untouched.
Post::withoutTimestamps(fn () => $post->increment("reads_{$lang}"));
Fixing the data took more work. I restored each updated_at from the date of the last git commit of its .md file. Checking the result turned up six posts whose dateModified was earlier than their datePublished, because their publish date was the time of the initial import. Now Post::lastModifiedAt() never returns a date before publication, and the JSON-LD, the sitemap, the RSS feed and the "Last updated" label all use it.
Part 2: telling the machine who I am
Back to the cyclist. If someone asks a chat about "Rubén Rangel", the model has to decide which Rubén Rangel they mean. My job is to give it a clear, unambiguous entity.
The home page has a JSON-LD block with ProfilePage, Person and WebSite. The Person has a stable @id (https://rubenrangel.org/#person), and posts reference it as their author instead of repeating the data. It also carries:
alternateNamewith 11 variants: with and without the accent, with my middle initial, with my full middle name and with the initial of my second surname. People spell my name in many ways, and each of them should lead to the same person.knowsLanguage, location (Medellín, Colombia), job title andsameAspointing to GitHub and LinkedIn.
Above the home page <h1> I added a plain line: "Rubén Rangel · Senior Full-Stack Software Engineer · Medellín, Colombia". It's the sentence I want a model to be able to copy as is. The other way around, the decorative blocks in the "now" section carry data-nosnippet, so they don't end up in a summary.
The entity isn't built from my site alone. I updated my GitHub bio and location, published a profile README, linked GitHub to LinkedIn and added the site to my LinkedIn contact info. Several sources agreeing is what gives a model confidence.
Part 3: giving AI something easy to read
An AI crawler can read HTML, but it doesn't have to be hard. I added three formats meant for machines:
/llms.txt, a dynamic Markdown file with who I am, my services, projects, demos and every post in both languages, each with its summary./llms-full.txt, the full text of every article in a single file.- A
.mdversion of every post, by appending.mdto its URL. Each page links to it with<link rel="alternate" type="text/markdown">.
The Markdown version must not compete with the page in search results, so it responds like this:
return response()
->view('blog._markdown', compact('post'))
->header('Content-Type', 'text/markdown; charset=UTF-8')
->header('X-Robots-Tag', 'noindex')
->header('Link', '<'.$post->url.'>; rel="canonical"');
The HTML page is the one that gets indexed, and the Markdown is for whoever reads it live.
I also added an RSS feed per language (/en/blog/feed.xml and /es/blog/feed.xml), with the full text of each post and absolute URLs. It serves feed readers and, as I explain below, distribution.
Part 4: notifying instead of waiting
Publishing and waiting for a crawler to come by can take weeks. Bing, and the products that rely on its index such as Copilot and ChatGPT search, accept IndexNow: a POST with the list of URLs that changed. I added it to the blog:sync command, which runs on every deploy, and it only notifies the posts that actually changed.
Google no longer accepts pings. For Google, the signal is a sitemap with an honest lastmod, which is why the fix in part 1 mattered so much to me.
This very post uses the last piece I added: scheduled posts. In its meta.json I set "published_at": "2026-10-29 11:05", Colombia time. It was deployed the day before, but it didn't show up in the listing, the sitemap, the feed or llms.txt until then. A command that runs every five minutes detects that it's now visible, notifies IndexNow and starts the LinkedIn draft, exactly once.
Part 5: letting crawlers in and seeing who shows up
None of the above matters if the CDN blocks the bots. In Cloudflare I set the AI crawlers to Allow and turned off "Block AI bots" and the managed robots.txt, so the app's own file is served, and it blocks nothing.
The AI Crawl Control panel showed 979 requests from AI crawlers in seven days. 459 got through, and Anthropic asked for the most (128), followed by Meta (97), Microsoft (49), Google (48) and Huawei (48).
The interesting part was the other 520. I exported the paths to CSV, and almost all of them were vulnerability scanners with an AI crawler user agent looking for secrets: .env, .ssh, terraform.tfstate, cloud credentials and source maps. They all got 403 or 404. None got a 200 or a 500. No legitimate crawler had failed on a real page.
The lesson: a user agent is not an identity. Letting AI crawlers in doesn't replace server security; it just lets the legitimate traffic through.
Part 6: measuring something that has no dashboard yet
Measuring GEO is still a manual craft. I settled on four sources:
| What | Where |
|---|---|
| Visits coming from an AI chat | GA4, with a custom channel group |
| Citations in Bing's AI answers | Bing Webmaster Tools, AI Performance |
| Crawlers getting through | Cloudflare, AI Crawl Control |
| Whether I'm named and cited | A manual test every two weeks |
In GA4 I copied the default channel group and added an "AI Assistants" channel above Referral, with this regex on the source:
.*(chatgpt|openai|perplexity|claude\.ai|copilot|gemini|deepseek|grok|you\.com).*
Google already ships its own "AI Assistant" channel, singular, so in reports I add both rows together.
The manual test is four fixed questions I ask ChatGPT, Perplexity, Claude and Copilot: who Rubén Rangel, the engineer in Medellín, is; a search by name and topic; and two technical questions that my articles answer. I write down whether I show up and whether they cite the site.
Distributing without duplicating
Publishing the same article on other platforms adds reach, but without a canonical it becomes duplicate content competing with the original.
- dev.to imports the English RSS feed and sets the canonical to my site. It doesn't render SVG, so diagrams go as PNG.
- Medium imports with
medium.com/p/importand sets the canonical on its own. It doesn't support SVG or tables either. - LinkedIn doesn't support a canonical on articles, so there I post a summary with the link, never the full article.
I turned the audit into a check
All of this lives in one file, docs/seo/estado.md, with a checklist, a log and nine checks I run against production: endpoints returning 200, the www redirect, canonicals, the home page entity, one <h1> per post, descriptions and titles within range, and lastmod against git.
That last one surprised me. Six posts seemed to have a lastmod one day ahead. It wasn't a bug: those were commits made after 7 p.m. in Colombia, and the sitemap publishes in UTC. The check now compares in UTC.
Without the file, every review would start from scratch. With it, "check SEO" means running a block of commands and comparing.
What I still don't know
I have no results to show yet, and I'd rather say so. As I write this, the site still doesn't show up when I search for my name. The manual IndexNow submission was accepted, but Bing's panel doesn't show any submissions yet. I'll start checking citations in Bing's AI answers in November.
What I do know is that before, the site sent mixed signals: wrong canonicals, fake dates and two versions of every URL. Now it sends consistent, verifiable ones. SEO and GEO don't guarantee you'll be cited, but without them it's very hard to be.
If the numbers say something in a few weeks, I'll write about it here.
Tools: Laravel, schema.org JSON-LD, llms.txt, IndexNow, Cloudflare AI Crawl Control, Google Search Console, Bing Webmaster Tools and GA4.