I work on four products that have nothing to do with each other. A psychometric test practice platform, a food scanner for people with gut conditions, a rental listing watcher, and a live pub quiz host. Different audiences, different domains, no shared users.
They do share a publisher, and I wanted search engines to know that, because reputation earned by one entity should not have to be earned again from scratch by the next thing it publishes.
The naive version of this goes wrong immediately.
The wrong property is sameAs
sameAs is the property everyone reaches for when they want to connect URLs. It is also an identity assertion: it says this thing and that thing are the same entity. Listing four product domains under sameAs claims that CogniPrep and a barcode scanner for IBS sufferers are one organisation with four addresses.
That is false, and worse, it is the kind of false that structured data validators happily accept and a search engine quietly ignores.
The correct shape is a hierarchy:
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,
})),
};
}
Each site emits that node as the parentOrganization of its own Organization. From the comment in our root layout:
// `parentOrganization`, not `sameAs`: the apps are siblings under
// one publisher, not several URLs for one thing, and sameAs would
// assert they are the same entity.
parentOrganization: publisherOrganizationLd(),
Siblings under a parent. Not aliases of one another.
@id is the join key, and it only works if it matches exactly
Schema.org nodes merge on @id. Two sites that describe the same company with no @id produce two anonymous nodes that no consumer will ever connect. Two sites that describe it with the same @id string produce one node with the union of what both said.
So the entire mechanism rests on one string being character-for-character identical in four separate codebases:
export const PUBLISHER = {
name: 'Sonacode Ltd',
id: 'https://cogniprep.app/about#sonacode',
} as const;
A trailing slash difference, a http instead of https, a different fragment, and you have two nodes again with no error anywhere to tell you.
The identifier is not a link, on purpose
That @id looks like a URL because schema.org @id values conventionally are. It resolves to a real page, but nothing in the graph depends on it resolving, and that is deliberate.
From the file:
Sonacode has no site in this graph on purpose. PUBLISHER.id is an identifier,
not a link, so nothing here points at a page that would then have to be kept
in step with it. If the company ever gets a site of its own, that URL replaces
the identifier here and in the three sibling repositories together.
The company has no website. Inventing a placeholder page to have something to link to would create a maintenance obligation in exchange for nothing. An @id is a name. A name does not have to be an address.
The file is copied four times and I am not going to fix that
Here is the part that goes against everything I would normally argue for.
lib/publisher.ts exists, in full, in all four repositories. Same app list, same blurbs, same intro paragraph. Exactly one line differs:
/** The app this repository builds. The one line that differs between copies. */
const SELF = 'cogniprep';
/** The other three, for the "Also from Sonacode" section on /about. */
export const SIBLING_APPS = SONACODE_APPS.filter((app) => app.key !== SELF);
I have written before about hunting duplicated constants inside this codebase, because a list that exists twice will drift. That instinct is correct within a deployment boundary and wrong across four of them.
The alternative is a shared package: publish @sonacode/publisher, depend on it from four apps. Now adding a fifth product means a version bump, a publish, and four dependency updates, and until all four have shipped, the graph is inconsistent anyway. You have bought a build pipeline and a release coordination problem, and you still have not removed the requirement that all four agree.
The agreement is the product here. The copies are just how it is stored. So the file says what to do instead of pretending:
Diff this file against the other repositories before changing it, and change
all four in the same sitting.
Four copies of a file that changes twice a year, with a written instruction on how to change it, is a smaller ongoing cost than a package. Duplication is bad because it drifts, and the mitigation is not always deduplication. Sometimes it is making the drift cheap to detect and giving the next person the instruction.
The shapes differ, and that is fine
I checked all four live sites while writing this. Three of them emit a single top-level Organization node with parentOrganization attached. One of them, Munchable, emits an @graph array containing its Organization, a WebSite node, a MobileApplication node and a FAQPage.
Different documents entirely. But:
https://cogniprep.app parentOrganization @id = https://cogniprep.app/about#sonacode
https://pub-trivia.app parentOrganization @id = https://cogniprep.app/about#sonacode
https://notifio.app parentOrganization @id = https://cogniprep.app/about#sonacode
https://munchable.app parentOrganization @id = https://cogniprep.app/about#sonacode
and each one lists the same four subOrganization ids. The nodes merge regardless of how each document is arranged, because merging is on identity, not on structure. This is the property that makes @id worth the discipline of getting the string right.
Structured data needs something on the page to back it
A claim in JSON-LD that nothing on the page supports is a manual-action risk. Google's guidelines are explicit that structured data should represent visible page content.
So /about renders the sibling apps as actual page content, from the same constant the JSON-LD is built from:
import { PUBLISHER, PUBLISHER_INTRO, SIBLING_APPS } from '@/lib/publisher';
// ...
<h2>Also from {PUBLISHER.name}</h2>
<p>{PUBLISHER_INTRO}</p>
One source, two renderings, and no way for the machine-readable claim and the human-readable one to disagree.
The page schema then points at the site-wide Organization rather than describing it again:
const aboutSchema = {
'@context': 'https://schema.org',
'@type': 'AboutPage',
'@id': `${websiteUrl}/about`,
url: `${websiteUrl}/about`,
name: 'About CogniPrep',
// Points at the Organization emitted site-wide rather than restating it,
// so there is one node for the company and not two.
mainEntity: { '@id': `${websiteUrl}/#organization` },
};
Same rule as before. Reference by @id, do not restate.
Two small things I liked writing
The intro paragraph that appears on all four sites opens by admitting the apps have nothing in common:
Sonacode builds four 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.
The comment above it explains why: "It says the unflattering thing on purpose. The apps share no audience, and a page that implied otherwise would be selling; what they share is a standard, and that is the only claim worth making to somebody who arrived from one of the others."
And the word "four" in that sentence is computed, because hardcoding it is how you end up with a paragraph saying "three apps" above a list of four:
const COUNT_IN_WORDS = ['no', 'one', 'two', 'three', 'four', /* ... */];
/** Spelled out, because a numeral that small reads as a typo in a sentence. */
const APP_COUNT = COUNT_IN_WORDS[SONACODE_APPS.length] ?? String(SONACODE_APPS.length);
The lookup table exists because "Sonacode builds 4 apps" reads like a typo in running prose. Deriving the count from the array and then spelling it out costs six lines and removes an entire class of embarrassing copy bug.
See it
Open cogniprep.app/about and view source. Search for sonacode and you will find the @id inside the parentOrganization block, alongside the four subOrganization entries.
Then do the same on munchable.app, notifio.app and pub-trivia.app. The surrounding documents look different, Munchable wraps everything in an @graph, but the @id string is identical in all four. That exact match is the whole mechanism, and it is verifiable from outside in about ninety seconds.
Top comments (0)