Skip to content

GEO Blog Overhaul: Making Posts Citable by AI, and Uncovering 500 Errors in Production Along the Way

I performed a round of GEO optimization on the blog: one-sentence summaries, structured data, full-text RSS, llms.txt, and share cards. Along the way, I uncovered a long-standing 500 error with the favicon, as well as an RSS feed being blocked by code in my own articles.

Reading Time
14 min read
Published on
2026.08.04

In short

The key to GEO isn’t keywords; it’s ensuring that generative engines can extract entire paragraphs of content: a self-contained conclusion for each article, structuring Q&As and step-by-step guides as structured data, and outputting the full text via RSS. The two truly tricky issues in this round weren’t in the code at all—they could only be spotted by testing the live site.

Blog GEO Overhaul

First, let’s define what GEO is: Generative Engine Optimization.

SEO is about getting content to rank high in search results, while GEO is about getting content cited in AI-generated answers——when engines like ChatGPT, Claude, Perplexity, and Google AI Overviews directly provide answers, does your site get recognized as a source of information, mentioned by name, or linked to?

This term originated from a 2023 paper (Aggarwal et al., later published in KDD).They conducted experiments to compare which rewriting techniques could improve the “citation rate,” and the conclusion was quite counterintuitive: Including specific statistics, citing authoritative sources, and inserting direct quotes from experts resulted in a 30–40% performance boost; whereas traditional keyword stuffing had virtually no effect.

This blog underwent an SEO overhaul in the previous round, so the foundation is solid. This time, we’re implementing GEO on top of that, and in the process, we uncovered two live issues—their commonality is that they look completely normal in the code.

Investigate the Live Site First, Then Read the Code

The first step isn’t to make changes, but to take stock of the situation. I used curl to check the sitemap, robots.txt, feed, and a few special paths one by one.

The good news is that the foundation is indeed solid: article pages are server-side rendered, and the full content is included in the HTML (AI crawlers generally don’t run JavaScript, so many SPA blogs are eliminated at this stage); robots.txt is User-Agent: * / Allow: / , and GPTBot, ClaudeBot, PerplexityBot, and Google-Extended all pass;Each page contains four JSON-LD blocks: BlogPosting , Person , WebSite , and BreadcrumbList , which are mutually referenced via @id to form a graph.

Here’s the bad news:

curl -s -o /dev/null -w "%{http_code}\n" https://www.cassianflorin.com/favicon.ico
500

The favicon consistently returns a 500 error

It’s not just the favicon. /apple-touch-icon.png , /llms.txt , and any non-existent URL with a dot under the root path—all return 500.

The root cause is a clash between two well-designed features:

export const config = {
  matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
};

The middleware uses .*\..* to allow all paths containing a dot (intended to avoid blocking static resources).Meanwhile, app/[locale] is the dynamic segment, which matches any single-segment path. As a result, /favicon.ico bypasses the middleware and is treated by the App Router as locale = "favicon.ico" , proceeding all the way to:

const { locale } = await params;
const messages = (await import(`../../messages/${locale}.json`)).default;

messages/favicon.ico.json (which doesn’t exist) → MODULE_NOT_FOUND → 500.

The fix is a single guard statement:

if (!isLocale(locale)) {
  notFound();
}

I also copied favicon.ico to the root directory of public/ ; that path now returns a 200.

Modification 1: A Single, Extractable Conclusion for Each Article

This is the modification with the highest return on investment.

Generative engines extract paragraphs that stand on their own outside of context. However, such paragraphs rarely exist in technical articles—conclusions are scattered across five subsections, and the model has to piece them together on its own.

So I added a tldr frontmatter field and filled it in for all 18 articles. It appears in three places:

  • The visible block at the top of the main text

  • The schema.org abstract for BlogPosting

  • The length validation for check-seo (display width: 30–200)

The component is intentionally kept very simple: a <section> containing data-tldr , with no nested tags between the label and the sentence, making it easy for the entire paragraph to be extracted.

Interestingly, that length rule immediately flagged five of my own excessively long entries; I had to shorten them to pass.

Modification 2: Handing Q&A and Steps Directly to the Engine

Added three schema.org builders, enabled via frontmatter opt-in:

TypeCoverageContent

| FAQPage | 7 articles | 27 sets of Q&A | | HowTo | 4 articles | 18 steps; each step in the Traefik article includes a verified chapter anchor | | DefinedTerm | 3 articles | Obsidian, Harness Engineering, SDKMAN |

After deduplication, there are 9 articles in total. All answers were extracted from the existing troubleshooting tables and main text of the articles; no new content was written.

All three builders employ defensive parsing; when frontmatter is written incorrectly, they return null instead of failing the build.

Modification 3: 18 Automatically Generated Share Cards

Previously, none of the articles had cover images; when shared, only the site’s default banner appeared.

The original plan was to use Next’s opengraph-image file convention, but the build threw an error:

Error: Catch-all must be the last part of the URL.

The article route is a catch-all ([...slug] ), and Next does not allow the image convention section to follow it. Changed it to a route handler app/og/[slug]/route.tsx , with the URL being /og/<slug>.png .

Here’s a pitfall to avoid: **Don’t place it under /api/ **, because robots.ts contains Disallow: /api/ , and social media crawlers can’t access images buried that deep.

The real headache is the font. next/og is based on Satori, which doesn’t include any Chinese fonts—if you don’t explicitly pass the font in, Chinese titles will render as a block of garbled characters. The full Noto Sans SC font is 18 MB, which is impractical to include in every image generation process.

The solution is to create a subset: fix the variable font to the 400 and 700 weights, then trim it based on the character set. Character set = site-wide corpus + 3,755 characters from the GB2312 Level 1 character set as a fallback—using only the existing corpus would cover just 1,143 characters, and new articles would easily exceed this limit.

The final result is 3,988 characters, with each of the two weights at 1.1 MB. These fonts are used only for rendering during build time and are not sent to visitors.

One additional security measure:

export const dynamic = 'force-static';
export const dynamicParams = false;

The font path is concatenated at runtime by process.cwd() . Next.js’s file tracking cannot detect this, so the TTF files won’t be included in the serverless function package. Therefore, unrendered slugs must return a 404 at the routing layer, rather than failing after entering the handler function due to missing fonts.

Modification 4: RSS Outputs Full Text, Then Gets Stuck on Its Own Articles

Previously, the feed only contained description (excerpts), and subscribers had to click back to the site to read the full text.

The challenge is that the full text is written in MDX: out of 18 articles, there are 470 instances of <div className> , 22 instances of <Callout> , and several custom components. contentlayer only exposes body.raw (MDX source code) and body.code (compiled bundle); there is no ready-made HTML.

I wrote a remark/rehype pipeline: stripping out presentational JSX (the reader has no stylesheets or React, and the Tailwind wrapper is pure noise), mapping elements like Callout to semantic HTML, and then extracting the content hidden inside props—The code in CopyableCodeBlock and the error in ErrorDisplay are both inside props; simply stripping them out would result in silent data loss.

Then the XML parsing threw an error:

xml.etree.ElementTree.ParseError: not well-formed (invalid token)

The number of CDATA tags is balanced, and the structure looks fine. After searching for a while, the culprit turned out to be this line:

width += /[⺀-￿]/.test(char) ? 2 : 1;

This is the regular expression in check-seo that calculates the display width for Chinese characters, with an upper bound of U+FFFF. It appeared in the feed because that article from the previous round of SEO updates referenced this code snippet.

U+FFFF is an invalid character in XML 1.0—neither CDATA nor numeric references can save it—since the specification simply does not allow its presence.

Process it into the \uXXXX escaped form: this is valid XML, and within the code block context, it remains equivalent and readable JavaScript—a better solution than simply deleting it. This also covers the C0 control character and the lone proxy entry.

The feed size increased from about 14 KB to 208 KB. Tag feeds remain in summary format—all 51 tag feeds are subsets of the same set of articles, so repeating the full text offers no benefit.

Refactoring 5: llms.txt

Following the llmstxt.org specification, output a site map at the root path. It is statically generated during build and its content automatically updates along with the articles.

The structure consists of an H1 heading, a one-sentence summary, and a paragraph of context, followed by a list of links organized into sections based on ## Posts , ## Pages , and ## Optional .For each article, tldr is used directly as the description—this is precisely what this field is for; a single crawl allows the model to capture the site-wide structure and the key points of each article.

Adoption rates are limited; this falls into the category of low-cost, easy-to-implement improvements—don’t expect immediate results.

Modification 6: Automatically Maintain lastmod The lastmod

field already exists, and both sitemap.ts and structured data are reading it. What’s missing is the value: none of the 18 articles had it filled in, so dateModified kept rolling back to datePublished —effectively telling the search engine that “these articles haven’t been touched since publication.”

Manual updates are bound to be forgotten, so we’ll derive the value from the Git history. There’s a not-so-obvious pitfall here:

The naive approach is to retrieve git log -1 --format=%cI -- <file> . However, when the script writes to lastmod , it creates a new commit. This commit becomes the latest change to the file, and the next build reads the new date, causing it to be written again—the script consumes its own output, causing the date to increment indefinitely.

Therefore, you must check the diff for each commit on this file, skipping those that only modified the lastmod: line, and keep going back until you reach the commit that actually changed the content.

Another pitfall is Vercel: by default, it performs a shallow clone, so git log cannot retrieve the history of older files. The script skips --is-shallow-repository when it detects it and uses the value already committed—the value is written and committed by the local build, and the build server is only responsible for reading it.

Embedding the Rules into the Build

If new conventions exist only in your head, you won’t remember them yourself six months later. So I’ve written everything into check-seo :

  • tldr Missing warning + 30–200 display width

  • Field format validation for faq / howto / definedTerm

  • OG card character missing detection (if a title uses a character outside the character set, it’s flagged early instead of being discovered as a blank box only when shared)

  • lastmod Errors reported before date The value of this set of rules lies in the fact that it turns the suggestion that “articles should have a one-sentence conclusion” into a rule that causes the build to fail.

If You’re Also Working on GEO

Following the order of my current round, it’s worth doing these things first:

  1. Test the live environment first, rather than reading the code. Use curl to check the URLs in the sitemap, robots.txt, and the favicon one by one to see the actual response codes. The two most valuable discoveries from this round came from doing exactly that.
  2. Make sure the AI crawler isn’t blocked. Many sites have excellent SEO but have inadvertently banned GPTBot entirely.
  3. Add a self-contained conclusion to every article. This offers the highest return on investment at virtually no cost.
  4. Convert Q&A and step-by-step instructions into structured data. Don’t count on rich media results; instead, focus on eliminating the need for model inference.
  5. Output the full text via RSS.
  6. Incorporate new conventions into build checks.

As for the content itself—the paper’s conclusion still holds true: specific numbers, authoritative citations, and direct quotes are more effective than any keyword trick. In this regard, GEO’s standards align with those of well-written articles.

A Conclusion That Hasn’t Changed

At the end of the article from the last round of SEO improvements, I wrote:“Use curl to test each URL in the sitemap one by one.”

This round has confirmed that once again—and even more thoroughly: the 500 error with the favicon wasn’t visible in the code, nor were the invalid characters in the feed—the latter was even caused by the code cited in that very article.

Just because the code reads fine doesn’t mean it works correctly in production. I’ll probably have to keep writing this for a few more years.

Keep reading