I ship four unrelated apps from one company: a rental alert monitor, a food label scanner for people with gut conditions, a psychometric test practice site, and a live pub quiz platform. Four domains, four repositories, four deploys, no shared audience whatsoever.
What they do share is a publisher. Getting a search engine to understand that took one TypeScript file, copied identically into all four repos, and a decision about identifiers that I got wrong the first time.
I wrote up the structured data side of this earlier from the other end of the graph, in One @id string, deliberately duplicated across four repositories. This is the same file seen from the Notifio repository, and the parts that post did not cover: the identifier that resolves to nothing, why four copies beat a package, and the paragraph on the about page that opens by admitting the apps are unrelated.
The problem
Four sites that link to each other look like exactly two things from the outside, and only one of them is good.
They either look like one company that builds four products, or they look like four unrelated sites doing reciprocal linking, which is a pattern search engines have spent twenty years learning to discount. Nothing in a link by itself distinguishes those.
Structured data can distinguish them, but only if the claim is made consistently. One site saying "I have three siblings" is a claim about itself. Four sites independently emitting the same publisher node, with the same identifier, is a corroborated fact.
The file
Every site emits an Organization for itself, site wide, from the root layout:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${APP_URL}/#organization`,
name: "Notifio",
url: APP_URL,
logo: `${APP_URL}/icon.png`,
description: "Notifio monitors rental listing sites and sends instant email and desktop alerts the moment new listings appear.",
parentOrganization: publisherOrganizationLd(),
}),
}}
/>
And publisherOrganizationLd() is the shared part, byte for byte identical in all four repositories:
export function publisherOrganizationLd() {
return {
"@type": "Organization",
"@id": PUBLISHER.id,
name: PUBLISHER.name,
subOrganization: SONACODE_APPS.map((app) => ({
"@type": "Organization",
"@id": app.organizationId,
name: app.name,
url: app.url,
})),
};
}
The whole mechanism is in the @id values. Each sub organization's @id is exactly the @id that the site for that app puts on its own Organization node:
{
key: "notifio",
name: "Notifio",
url: "https://notifio.app",
organizationId: "https://notifio.app/#organization",
...
}
So a crawler that has parsed any one of the four sites has a graph with four organization nodes in it, one of them fleshed out with a logo and a description, the other three as stubs. When it reaches the second site, the stub for that site merges with the full node, because they carry the same identifier. Nodes merge on @id. That is the entire trick, and it is why the identifiers have to be picked once and then never casually edited.
The identifier that is deliberately not a link
The company itself has no website, and its @id is:
export const PUBLISHER = {
name: "Sonacode Ltd",
id: "https://cogniprep.app/about#sonacode",
} as const;
That URL pattern looks like a mistake. The publisher node's identifier points at a page on one of its products.
It is deliberate, and the reason is that in JSON-LD an @id is an identifier, not a link. It needs to be globally unique and stable. It does not need to resolve, and nothing is obliged to fetch it.
The alternative was registering a company domain to have something "proper" to point at. That would have meant a fifth site to keep alive, with content, a certificate, and a page that would have to stay in agreement with four other repositories. An identifier that resolves to nothing costs nothing and breaks nothing.
The rule for when that changes is written into the file next to it, because the expensive failure here is a partial migration:
If the company ever gets a site of its own, that URL replaces the identifier here and in the three sibling repositories together.
Two sites claiming different identifiers for the same publisher is strictly worse than the arrangement I have now: instead of one company with four products, the graph describes two companies with a suspicious overlap.
Four copies of a file, on purpose
The obvious move is to publish this as a package and depend on it from all four repos. I did not, and I would make the same call again.
A private package means a registry, a version, a release step, and four dependency bumps every time a blurb changes. For a file that changes roughly twice a year, that pipeline is more moving parts than the problem has. The copies are governed by a comment at the top of the file instead:
/**
* The data in this file is the same in every Sonacode repository apart from
* SELF, which names the app this repository builds. Keeping the four copies in
* agreement is the whole point ... Diff this file against the other
* repositories before changing it, and change all four in the same sitting.
*/
One line differs per repository:
/** The app this repository builds. The one line that differs between copies. */
export const SELF = "notifio";
export const SIBLING_APPS = SONACODE_APPS.filter((app) => app.key !== SELF);
That single constant means the "Also from us" section on each site derives itself. No repo maintains a hand written list of its three siblings, so no repo can forget to remove an app or list itself.
Duplication is a cost you pay at edit time. Coupling is a cost you pay at release time. For something edited twice a year and released with every deploy, duplication is the cheaper of the two. The mistake would be to duplicate it silently, without the comment that tells the next person the copies are load bearing.
The paragraph that says the unflattering thing
The same file holds the prose for the cross promotion section, and it opens by admitting the apps have nothing to do with each other:
export const PUBLISHER_INTRO = `Sonacode builds ${APP_COUNT} apps, and they look unrelated because they are: a quiz night for a pub and a food scanner for people with IBS share no audience at all. What they have in common is how they are built...`;
A reader who lands on the Notifio about page from a food scanner knows perfectly well that the apps are unrelated. A page that implied otherwise would be selling, and would be caught out in one sentence. Naming the gap first is the only way the next sentence gets read at all.
One small detail in that template that I like more than it deserves:
const COUNT_IN_WORDS = ["no", "one", "two", "three", ...];
const APP_COUNT = COUNT_IN_WORDS[SONACODE_APPS.length] ?? String(SONACODE_APPS.length);
The count is derived from the array, so a fifth app updates the sentence. Spelled out in words, because "Sonacode builds 4 apps" reads like a typo in a paragraph, and the fallback to a numeral is there so a future tenth app degrades to slightly awkward rather than to undefined.
Have a look at the output
The page all of this feeds is notifio.app/about, including the "Also from Sonacode" section that derives itself from the array. View source on any page of the site and the first thing in the body is the organization node with the publisher graph attached.
If you want to see the merge from the other end, cogniprep.app, munchable.app and pub-trivia.app emit the identical publisher node with the identical identifiers, which is the entire point of the exercise. Notifio itself, the rental alert app this repository actually builds, is at notifio.app.
Top comments (0)