Building in public, week 17: turning one feature into a page cluster (and the internal-linking layer nobody sees)
The problem: a hub with nothing pointing at it
The background remover lives at /remove-background. That is the hub. The plan was classic hub-and-spoke: one general tool page, then use-case spokes that each target a specific intent (removing a signature background, prepping an Amazon product photo, and so on). I built two spokes this week.
But halfway through, I looked at how internal links actually worked on the site and found the real problem: nothing linked from the hub to the spokes. The spokes linked back to the hub in their body text, but the hub had no idea they existed. Neither did the ~180 converter pages.
Tool links on the site were hardcoded in a frontend constant, roughly:
export const IMAGE_TOOLS = [
{ label: "Compress JPG", href: "/compress/jpg", tool: "compress" },
{ label: "Resize Image", href: "/resize-image", tool: "resize" },
{ label: "Crop Image", href: "/crop-image", tool: "crop" },
{ label: "Images to PDF", href: "/images-to-pdf", tool: "convert" },
] as const;
That list covered the converter tools. It did not include the background remover or its spokes at all. So the new pages were orphans: reachable only through the sitemap, with no internal links carrying any signal to them. For a domain that is still young and still earning Google's trust, orphan pages get discovered slowly and rank even slower.
The fix: one constant as the source of truth
Instead of hardcoding links in three different places, I made a single constant describe the whole cluster:
export const BACKGROUND_TOOLS = [
{ label: "Background Remover", href: "/background-remover" },
{ label: "Signature Background", href: "/remove-signature-background" },
{ label: "Amazon Product Photo", href: "/amazon-product-photo-background" },
] as const;
Then a small server component renders it, with an exclude prop so each page drops itself from the list:
export default function BackgroundTools({ exclude }: { exclude?: string }) {
const tools = BACKGROUND_TOOLS.filter((t) => t.href !== exclude);
if (tools.length === 0) return null;
return (
<section>
<h2>Background Removal</h2>
<div>
{tools.map(({ label, href }) => (
<Link key={href} href={href}>
{label}
</Link>
))}
</div>
</section>
);
}
The nice part is that the same constant became the source of truth for detecting a background-removal page too. The main layout used to repeat the same array-of-slugs check three separate times. That collapsed into one line:
const isBgRemoval = BACKGROUND_TOOLS.some((tool) => tool.href === `/${mode}`);
So now: the hub links to both spokes, each spoke links to the hub and its sibling, and the whole thing renders only on background-removal pages. Adding a fourth spoke later is one line in the constant and it is cross-linked everywhere automatically. I also added the same list as a footer column, which puts the links on every page of the site, not just the cluster.
The bug that only shows up in production
The two spokes are stored as structured content in the database, and each section body is rendered with dangerouslySetInnerHTML inside a <p>. On one spoke I added a small "before you upload" checklist as a <ul>. It looked fine locally. In production, React threw a hydration mismatch.
The reason is plain once you see it: a <ul> is not valid inside a <p>. The browser silently auto-closes the paragraph before the list, so the DOM the browser builds does not match the HTML React rendered on the server, and hydration fails.
The fix was to keep the section body to inline content only, so the checklist became <br>-separated lines instead of a real list.
The lesson I actually kept: I added a build-time guard that scans every section body for block-level tags (ul, ol, li, div, table, headings) and fails the build if it finds one. The class of bug is now impossible to ship again, which matters more than the one fix.
The honest numbers
Building in public means posting the numbers even when they are boring or bad.
- Background pages live: 3 (hub + 2 spokes). The plan asked for 1 or 2, so this part went well.
- Benchmark on the real VPS: the background remover runs at about p50 2.4s, p95 9.6s per image on a 4-core box.
- AI citations: the site is cited 75 times across Microsoft Copilot, mostly on HEIC content. That is visibility that never shows up in a rank tracker, and I only found it by looking.
- Google: still sandboxed. Impressions exist, positions are deep (average around the mid-40s), clicks are a trickle. Bing, meanwhile, ranks the same pages on page one. Same content, different trust curve. This is normal for a young domain and I am not fighting it, just waiting it out while the content compounds.
What week 18 looks like
More spokes, but slowly. Google's spam updates punish thin template-swap pages, so the rule is one or two good pages with genuine unique data, not ten shells. On the list: a couple more use-case spokes, a low-competition webp remove background page I found in my keyword data, and continuing the slow grind of backlinks to lift domain authority, which is the real ceiling right now.
The feature was week 16. The leverage was week 17. If you are building something similar, the thing I would repeat is this: shipping the feature is the easy half. Making it findable, and keeping the pages honestly connected, is the half that actually decides whether anyone ever sees it.
You can try the background remover here: https://convertifyapp.net/remove-background
Comments
No comments yet. Start the discussion.