nemo.foo
back to writing

$ nemo.foo /blog

7 min readby nemo

We Stopped Ranking for ShipClip

Our homepage was ranking around position 3 for our own brand name. Last week it dropped to 113. Google's ShipClip entity got assigned to our Instagram profile. Fixing it required structured data across three sites, not one.

blogshipclipseo

TL;DR: Our homepage was ranking around position 3 for the query “shipclip.” Last Friday it dropped to 113. Google looks like it assigned the ShipClip brand entity to our Instagram profile instead of our site. The fix wasn’t one site. It was three. ShipClip, Ligma Digital (the LLC that publishes it), and nemo.foo (me) all needed to declare matching, cross-referencing entities so Google could resolve “ShipClip” to the homepage instead of the Instagram profile.

The Drop

Open Search Console, filter to query=shipclip, last 3 months. The averages look fine: 29 clicks, 111 impressions, 26.1% CTR, average position 4.3.

The averages lie. Eight weeks of position 3 are subsidizing the most recent week.

Switch to daily and the cliff is obvious. The line is flat in the single digits for two months, then snaps up at the end of April.

Friday May 1: average position 113 for the query “shipclip.”

The Pages tab confirms it isn’t just the homepage. Google pushed every shipclip.app URL off the first page for our own brand name. The changelog sits at position 112. The blog at 12.7. Even the homepage’s rosy 2.9 is mostly historical. Last week it was nowhere near that.

What I Think Happened

When you search “shipclip” the SERP now leads with our @shipclipapp Instagram profile. The homepage isn’t reliably on page one.

My read: Google’s Knowledge Graph picked an entity for the brand “ShipClip” and resolved it to the Instagram account, not the site. From its point of view that’s defensible. Instagram has an unambiguous brand profile, structured data, verified social signals. Our site had a single Organization JSON-LD on /about, no entity declaration on the homepage, no rel=me linking the social profiles back to us. The whole brand was scattered across pages with no canonical anchor.

Once Google decides Instagram is the ShipClip entity, every shipclip.app URL has to fight upstream for queries that should be a layup.

The First Fix Was Half a Fix

I shipped the obvious changes Friday night: sitewide JSON-LD on shipclip.app declaring an Organization, refactored /about to enrich that org by @id instead of redeclaring it, rel=me on the footer social links. Standard playbook for a brand entity problem.

Then I read it back and realized I’d built a clean canonical entity for ShipClip. I’d also stuffed every sameAs link I could find into it. My personal Gravatar. My Disqus profile. A Cal.com booking link. A documentation site. Two mac-founder-toolkit repos that are a different product entirely.

The Organization entity for ShipClip was claiming that ShipClip is my Gravatar profile. That ShipClip is the Cal booking flow. That ShipClip is my hashnode blog. To Google, an Organization claiming to be eighteen different things is harder to resolve than one claiming to be five.

The mess wasn’t accidental. It was the natural result of conflating three entities that have a real relationship but aren’t the same thing:

  • Ryan / Nemo — the human
  • Ligma Digital — the LLC, the parent
  • ShipClip — the product the LLC ships

Google has separate Knowledge Graph slots for all three. Treating them as one bag of links made the brand entity fuzzy enough for Instagram to win it.

The Real Fix: Three Entities, Three Repos

Each entity gets its own canonical @id, declared on its own primary domain:

  • https://nemo.foo/#person — declared sitewide on nemo.foo
  • https://ligma.digital/#organization — declared sitewide on ligma.digital
  • https://shipclip.app/#organization — declared sitewide on shipclip.app

The relationships are explicit: ShipClip’s parentOrganization points at Ligma Digital’s @id. Ligma Digital’s subOrganization points at ShipClip’s @id and its founder points at the Person’s @id. The Person’s worksFor points at Ligma Digital’s @id. None of the three entities redeclares another. They reference by @id and let Google follow the graph across domains.

Each entity’s sameAs only contains profiles for that entity:

  • ShipClip@shipclipapp on X, Threads, Instagram, Facebook, TikTok. Brand-level directories: G2, AlternativeTo, SaaSHub, Crunchbase, Peerlist’s project listing.
  • Ligma Digital — LinkedIn company page, Facebook page. That’s it. Word-of-mouth shop.
  • Ryan — Gravatar, Dev.to, Hashnode, Medium, Reddit, X (@FakeUncleNemo), Bluesky, GitHub, GitLab, YouTube, IndieHackers, Peerlist’s user profile, ProvenExpert.

The Cal.com link is a contactPoint, not an identity. The docs site is subjectOf, not identity. The mac-founder-toolkit repos are a different product and don’t appear anywhere in this graph at all.

Typed ImageObject for the logo

{
  "@type": "ImageObject",
  "@id": "https://shipclip.app/#logo",
  url: "https://shipclip.app/icon.png",
  contentUrl: "https://shipclip.app/icon.png",
  width: 512,
  height: 512,
  caption: "ShipClip"
}

A bare logo: "https://shipclip.app/icon.png" string is valid Schema.org but skips Google’s specific guidance for the Logo feature. Knowledge Panel eligibility wants a typed ImageObject with explicit dimensions. Same pattern on each domain. ShipClip’s icon is 512×512. Ligma Digital’s is 400×400.

rel=me on outbound social links, both ways

<a href="https://x.com/shipclipapp" rel="me noopener noreferrer">

sameAs in JSON-LD is the site claiming the profiles are theirs. rel=me on the actual <a> tags is the same claim in HTML. Together they let Google see a consistent story regardless of which surface it’s parsing.

The other half, the profiles claiming the site back, happens by filling in the “Website” field on each profile with the canonical URL. Some platforms (X, GitHub, GitLab) render those links with rel="me" automatically; some don’t. Either way, you do the same thing on your end: paste the URL, save the profile.

The sitemap had no freshness signal

Resubmitting the sitemap in Search Console is the obvious “please recrawl” lever after a structured-data change. It almost did nothing.

The shipclip.app app/sitemap.ts set lastModified on blog posts and reviews (from content dates), but no other URL had it. Homepage, pricing, programmatic landers, every /tools/* route. All bare. No <lastmod> element at all.

Google fetches a sitemap, compares each URL’s <lastmod> to its last crawl, and queues a recrawl if newer. With no <lastmod>, Google skips that decision. The URL stays on whatever schedule it’s already on, which on a small site can be weeks. The resubmission looks productive (green “Success” status, no errors) but pushes almost no recrawl signal for the bulk of the site.

The fix is a single constant:

// app/sitemap.ts
const BUILD_TIME = new Date();

export default function sitemap() {
  return [
    { url: BASE_URL, lastModified: BUILD_TIME, /* ... */ },
    // every static URL gets BUILD_TIME
    // blog posts and reviews keep their content-date lastmod
  ];
}

BUILD_TIME evaluates once when the module loads. On a self-hosted Docker/k3s stack, that’s during next build inside the Docker builder stage. Every deploy bakes a fresh timestamp into the static XML output.

This isn’t gaming Google. The pages did change at deploy time. The rendered HTML now contains different sitewide JSON-LD, a different footer build ID, a different layout. Reporting that as lastmod is accurate.

One more thing. If GSC’s “Last read” doesn’t update within 48 hours of the next sitemap fetch, delete the sitemap entry entirely and re-add it. That forces Google to treat it as a brand new submission instead of a re-fetch of an already-known one, which sometimes gets deduplicated against cache.

Coordinating across three Next.js sites

The cross-references only work if every site declares its own canonical @id and every other site references the exact same @id string. Three repos shipped synchronized commits: Skrank (shipclip.app), ligma-digital (ligma.digital), and nemo.foo. Deploy order matters: nemo.foo first (canonical Person source), Ligma Digital second (Organization source, references the Person), Skrank third (references both). A crawler that hits Skrank before the others have deployed will see @id references that don’t yet resolve anywhere.

Now What

This is still brand SEO, not technical SEO. The fix is a hypothesis: a clean three-entity model with cross-domain @id references gives Google enough to re-anchor the ShipClip entity to the homepage. There’s no guaranteed timeline. Entity reassignment can take days to weeks of recrawl, re-indexing, and Knowledge Graph rebuild before the SERP catches up.

The verification plan is concrete:

  • GSC graph in 2-4 weeks. If position recovers below 5 for “shipclip,” the fix worked.
  • Knowledge Graph API. Hit kgsearch.googleapis.com for “shipclip” weekly. When the homepage starts appearing, the entity has been reassigned.
  • If neither moves, the next move is off-site signals: a Wikidata entry for ShipClip (highest-leverage thing I can place myself), Crunchbase profile cleanup, and pushing each social profile’s Website field to point at the canonical URL.

Either way, this was a lesson in something I’d underweighted: brand-query SEO is its own discipline, separate from keyword SEO. You can win every long-tail term and still lose your own name. And the fix isn’t always on the site you think. It’s wherever the entity model is fuzzy.

~/subscribe
$ Get the next post in your inbox.
$ One email per post. No filler.