I Rebuilt Product Pages for AEO as a Solo Founder — Canonical URLs, Category Hubs, and Durability Proof

Product Engineering

I went into this week thinking it was an SEO sprint.

It turned into something more like a trust audit.

OnlyBuyForLife is a product discovery site built on r/BuyItForLife community recommendations — durable stuff, backed by upvotes and real discussion threads. The core bet is that community signal is more trustworthy than paid placement.

But community signal only matters if the site itself holds up its end. So I spent the week on three things: canonicals, category hubs, and getting durability proof actually visible on product pages. Here's what I changed and what surprised me.


The Problem I Was Actually Solving

The site had a messy URL situation. Products could be reached at /items/{id}, at /product/{slug}, and in some cases through category filter state that lived in the homepage rather than in crawlable URLs. No single product had one clear identity.

For answer engines — which need a stable URL to cite when summarizing a recommendation — this is a real problem. But honestly, it was a product problem before it was an SEO problem. If the same thing lives at multiple addresses, trust and signal both get fragmented.

I also had product pages that claimed "this is durable" but showed almost no evidence. A category label and a Reddit upvote count isn't proof. I needed to surface the actual reasoning.


Fix 1: Canonical Product URLs via 301

The canonical route is /product/{slug}. Everything else redirects there permanently.

The legacy /items/{item_id} route now does a database lookup, resolves the canonical slug, and issues a 301:

@router.get("/items/{item_id}", response_class=HTMLResponse)
def redirect_legacy_item_url(item_id: int):
    response = (
        ctx.client.table("item")
        .select("id, slug, name")
        .eq("id", item_id)
        .eq("is_public", True)
        .limit(1)
        .execute()
    )

    if not response.data:
        raise HTTPException(status_code=404, detail="Product not found")

    item = response.data[0]
    canonical_slug = ctx._canonical_slug_for_item(item)
    canonical_path = ctx._build_product_canonical_path(canonical_slug)
    return RedirectResponse(url=canonical_path, status_code=301)

Simple. But this matters a lot: every share link, every internal link, every citation from an answer engine now points to one place. The relevance signal stops leaking.

What I learned: canonical URLs are a product decision disguised as an SEO setting. Fixing them forces you to be opinionated about what a product's identity actually is.


Fix 2: Category Hubs That Crawlers Can Actually Find

Before this, categories existed as homepage filter state. You could browse by category, but there were no real URLs at /categories/cookware or /categories/office-chairs. Crawlers saw a single homepage. Category-level intent was invisible.

I added explicit routes — /categories (hub index) and /categories/{slug} (landing pages) — server-rendered with their own canonical URLs and proper pagination:

@router.get("/categories/{slug}", response_class=HTMLResponse)
def get_category_landing(request: Request, slug: str, page: int = Query(default=1, ge=1)):
    page_data = ctx._fetch_public_items_by_category_slug(
        slug,
        page=page,
        page_size=12,
        context="get_category_landing",
    )

    current_page = int(page_data.get("current_page") or 1)
    has_more = bool(page_data.get("has_more"))
    category_slug = str(page_data.get("slug") or "")

    canonical_url = ctx._build_category_canonical_url(category_slug, page=current_page)
    prev_page_path = (
        ctx._build_category_canonical_path(category_slug, page=current_page - 1)
        if current_page > 1
        else None
    )
    next_page_path = (
        ctx._build_category_canonical_path(category_slug, page=current_page + 1)
        if has_more
        else None
    )

The pagination handles canonical correctly: page 1 gets the base URL, subsequent pages get ?page=N — not redirected to page 1, which would trash the content.

I also tightened what gets shown in these pages. Items only appear if they're public, have an active offer, and have approved + published enrichment:

filter_args: list[tuple[str, str, object]] = [
    ("eq", "is_public", True),
    ("eq", "has_active_offer", True),
    ("eq", "enrichment_status", "approved"),
    ("eq", "enrichment_is_published", True),
    ("eq", "category_slug", normalized_slug),
]

rows = self.fetch_item_rows(filter_args, "score", True, offset, limit + 1, context=context)
rows = [row for row in rows if self.item_is_catalog_eligible(row)]

What I learned: "clear information architecture beats clever metadata" sounds obvious until you actually audit what's crawlable. A lot of content was effectively invisible.


Fix 3: Durability Proof on Product Pages

This was the meatiest change and the most interesting one to think through.

A product page that says "durable" without showing why is just an assertion. For a site called OnlyBuyForLife, assertions aren't enough. I needed to surface the actual evidence: why it lasts, common failure modes, maintenance notes, which variant to buy, best-for and not-for guidance, and links back to the source Reddit threads.

I added an enrichment pipeline that synthesizes this from community discussion, human review gates it, and then the template renders it only when it's approved and published:

{% if show_ai_answer and ai_summary %}
<section id="product-answer" class="pb-4 sm:pb-6">
    {% with heading="Why this product is recommended", anchor_id="product-answer-block", ai_summary=ai_summary %}
    {% include "partials/ai_answer_block.html" %}
    {% endwith %}
</section>
{% endif %}

{% if show_enrichment_content %}
<section id="durability-proof" class="pb-6 sm:pb-8">
    <div class="card bg-base-100 shadow-xl">
        <div class="card-body p-4 sm:p-5 md:p-6 gap-4">
            <h2 class="card-title text-xl md:text-2xl">Durability Proof</h2>
            {% if item.why_it_lasts %}
            <p class="text-sm text-base-content/90">{{ item.why_it_lasts }}</p>
            {% endif %}
        </div>
    </div>
</section>
{% endif %}

The show_enrichment_content flag only resolves to True when enrichment_status == "approved" and enrichment_is_published == True. Nothing leaks to the page until it's been human-reviewed. Items without approved enrichment still get an AI summary block if they qualify — but the full durability proof section stays hidden until it's ready.

The route handler also assembles freshness metadata (last reviewed date, last updated) and approved source threads, handing all of it to the template cleanly before any rendering happens. Logic stays in Python, templates stay dumb.

What I learned: "durable" is a claim. Claims need receipts. Once I framed it that way, the implementation became obvious — you gate visibility on evidence quality, not on content existence.


The Pattern Underneath All Three

The common thread is that all three fixes push decisions earlier in the stack:

  • URL identity is resolved at the route layer, not inferred at render time
  • Catalog eligibility is a database filter, not a template conditional
  • Proof visibility is a status gate, not a content check

This is the part that's hard to get right as a solo founder when you're moving fast. It's tempting to push logic into templates because it ships faster. But once you do, observability drops, defaults get inconsistent, and you're chasing rendering bugs instead of building features.

Building the route handlers to produce a fully-assembled view model before touching the template — that was the actual discipline shift.


What I'm Watching Next

  • answer engine citation behavior on product pages
  • category-level impression growth in Search Console
  • engagement differences between proof-rich and proof-thin pages

It'll take a few weeks to get meaningful signal. But the architecture is cleaner now, and the site is honest about what it knows and doesn't know for each product.

That last part matters more than any ranking factor.


Build in Public

I'm building OnlyBuyForLife solo and writing about what I'm learning along the way. If you're working on recommendation systems, AEO, or catalog trust problems and want to compare notes — reach out through the site or drop a comment.