Back to News & Insights
SEO October 2, 2026 · 5 min read

Four of our apps share no audience, and one hand-copied TypeScript file is what tells Google they share a publisher

A quiz app for pubs and a food scanner for IBS have nothing in common except a company. Declaring that to a crawler means four sites emitting the same Organization node, byte for byte, from a file that is deliberately duplicated.

Four of our apps share no audience, and one hand-copied TypeScript file is what tells Google they share a publisher

Our company ships four apps. A live quiz night for pubs, a practice tool for psychometric hiring tests, a barcode scanner for people with gut conditions, and a watcher for rental listings. If you were handed that list cold you would assume four unrelated indie projects that happened to be built by the same person.

A crawler assumes the same thing, and the assumption is worse than neutral. Four small domains that link to each other, with nothing declaring why, is the shape of a link scheme. The relationship either gets stated in a machine readable way or it gets guessed at, and you do not want it guessed at.

The way you state it is schema.org, and the implementation is the part nobody writes about: the same file, copied by hand into four repositories, with one line different in each.

Every one of the four sites emits that, with the same @id and the same four children in the same order. A crawler that has seen any one of the sites has seen the whole family. Agreement is the entire mechanism: four sites making the same claim about who publishes them is evidence, and four sites each making a slightly different claim is noise.

Then each site emits a fuller node about itself, naming that publisher as its parent:

The @id here and the @id of this app inside the parent's subOrganization list are the same string, so they are the same node, and a consumer merges them. That is what makes the sibling listing on one site and the self description on another resolve to one entity rather than two.

The company does not have a website in this graph, and that is deliberate. An @id in JSON-LD is required to be an IRI, but it is not required to resolve to anything. Pointing it at a fragment on a page that already exists gives all four sites a stable string to agree on, without creating a fifth site that would then have to be maintained in step with them.

If the company ever gets a site, that URL replaces the identifier in all four repositories in the same sitting. Until then, the thing I most wanted to avoid was a page whose only reason to exist is to satisfy a schema.

That is the only line that is not identical across the four copies. It is also the single most likely thing to be wrong, because the way a new repository gets this file is that somebody pastes it and edits one word.

as const satisfies rather than a : readonly SonacodeApp[] annotation, and the difference is not stylistic. The annotation widens every key to string, AppKey becomes string, and a typo in SELF compiles perfectly happily. What you get then is a site that fails to find itself, falls through to listing itself as one of its own siblings, and emits a graph claiming it is its own sibling. With satisfies, the literal types survive, so a misspelling is a compile error in the one place a misspelling was ever going to happen.

This is the second time that exact trick has paid for itself in this codebase. The first was the content registry, where the same widening would have let a page declare a cluster that does not exist.

The per app @id used to be a field on each entry. It was always exactly ${url}/#organization, which means it was a second copy of the domain sitting next to the first one, free to drift away from it.

Drift there is unusually nasty because it is invisible. Nothing breaks, no page 404s, no test fails. You get two Organization nodes for one app in whatever Search Console decides to show you three months later, and reconstructing why involves diffing JSON-LD across four deployments.

The prose on four about pages says "four apps" because the array has four entries. Spelled out in words, because a numeral that small reads as a typo in a sentence. Hardcoding the word is how you end up with a page confidently saying "three apps" above a list of four.

Every URL in that file is the production one, in every environment. They are identifiers shared across four deployments, not links to whatever host the current build happens to answer on.

Which creates an obvious hazard: a preview deploy emitting the production @id is a second thing claiming to be the real site. So the layout only emits the graph when the build knows it is the real site:

The alternative, rewriting the identifiers per environment, is strictly worse. It makes the preview's graph internally consistent and externally meaningless, and it means the thing you test is not the thing you ship.

Duplicating a file across four repositories is the sort of thing that gets flagged in review, and the reflex fix is a shared package. We did not, for a boring reason: four repositories deploy independently, and a version skew in a shared package would mean two sites emitting different versions of a node whose entire value is that it is identical everywhere. The coupling is real either way. The duplicate makes it visible.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation