DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Four sites, one publisher node, and an @id that deliberately is not a link

Notifio watches rental listing pages and tells you the moment something new appears. It is one of four apps I build, and the other three are a psychometric test trainer, a food scanner for people with gut conditions, and a live quiz night for pubs. Four domains, four repositories, four Next.js deployments, and no shared audience whatsoever.

That last part is a problem, and not the marketing one it sounds like.

Four small sites on four unrelated domains that link to each other is, structurally, indistinguishable from a link scheme. Nothing on any of them says "these are the same publisher" in a form a crawler can act on. The About page saying "also from Sonacode Ltd" is prose, and prose is not an assertion.

The fix is about forty lines of JSON-LD, and the interesting part is not the schema. It is the decision that the publisher is an identifier rather than a link.

One node, emitted identically by four sites

Each site emits an Organization for itself, with the publisher hanging off it as parentOrganization:

{/* Who publishes this site, in the form a crawler reads.
    `parentOrganization` comes from lib/publisher.ts, which is kept in
    agreement across all four Sonacode repositories: every site emits the
    identical publisher identifier, and that agreement is what says the
    domains share a publisher. */}
<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(),
    }),
  }}
/>
Enter fullscreen mode Exit fullscreen mode

And publisherOrganizationLd returns the publisher with every app it publishes underneath it:

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,
    })),
  };
}
Enter fullscreen mode Exit fullscreen mode

Two @id values are doing all of the work here. https://notifio.app/#organization is what this site calls itself everywhere, on every page, forever. PUBLISHER.id is what all four sites call the company. Repeat those two strings consistently and a consumer can merge the nodes; get one character wrong on one site and that site is an orphan.

The identifier is deliberately not a link

Here is the part I went back and forth on. PUBLISHER.id is a URL:

export const PUBLISHER = {
  name: "Sonacode Ltd",
  id: "https://cogniprep.app/about#sonacode",
} as const;
Enter fullscreen mode Exit fullscreen mode

It is a fragment on a page belonging to one of the four apps, not a company homepage. There is no sonacode.com. From the file header:

/**
 * 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.
 */
Enter fullscreen mode Exit fullscreen mode

The temptation was to register a domain, put up a one-page company site, and point @id at it. That would have been three more things to maintain and one more page that could go stale or 404, in exchange for nothing a crawler needs. An @id in JSON-LD is a name for an entity. It looks like a URL because that is the convention, and it is allowed to resolve to something, but it is not obliged to. The only property that matters is that all four sites spell it the same way.

If you have ever hesitated over "but there's nothing at that URL", that is the hesitation to let go of. It is the same category of mistake as assuming every href is a vote, which I wrote about in a link is not a link until you have read its rel attribute.

The shape is allowed to differ. The id is not.

I checked what the four live sites actually serve, and three of them nest the publisher inside parentOrganization while the fourth uses a @graph with the publisher as a sibling node and a bare @id reference:

{"@context":"https://schema.org","@graph":[
  {"@type":"Organization","@id":"https://munchable.app/#organization",
   "name":"Munchable",
   "parentOrganization":{"@id":"https://cogniprep.app/about#sonacode"}},
  {"@type":"Organization","@id":"https://cogniprep.app/about#sonacode",
   "name":"Sonacode Ltd",
   "subOrganization":[ ... all four ... ]},
  {"@type":"WebSite","@id":"https://munchable.app/#website", ...}
]}
Enter fullscreen mode Exit fullscreen mode

My first reaction was that one of the four repositories had drifted. It has not. Those two documents say exactly the same thing, because a consumer merges nodes on @id before it interprets anything: nesting an object and referencing it by id are two spellings of one edge. The site with the richest graph, which also emits WebSite and BreadcrumbList nodes, uses the flat form because flat is easier to extend.

That is worth internalising before you spend an afternoon making four templates byte-identical. The contract between the sites is the identifier, not the document shape.

Read it off the live pages yourself, from the console on any of them:

[...document.querySelectorAll('script[type="application/ld+json"]')]
  .map(s => s.textContent)
  .filter(s => s.includes("Sonacode"))
Enter fullscreen mode Exit fullscreen mode

You will get the same subOrganization list of four @id values on notifio.app, cogniprep.app, munchable.app and pub-trivia.app.

Markup with nothing behind it is a liability

The rule I hold to on all four sites is that structured data has to describe something a human can see on the page. /about exists partly to give the publisher claim something to stand on, and the page renders from the same file the markup does:

/**
 * The Organization block in app/layout.tsx declares Sonacode Ltd as the parent of
 * all four apps, and each sibling site carries the mirror of it. Structured data
 * with nothing on the page to back it up is a manual-action risk, so the list
 * below is rendered from lib/publisher.ts rather than typed out a second time.
 */
Enter fullscreen mode Exit fullscreen mode
<Section title={`Also from ${PUBLISHER.name}`}>
  <p>{PUBLISHER_INTRO}</p>
  {SIBLING_APPS.map((app) => (
    <div key={app.key}>
      <h3><a href={app.url}>{app.name}</a></h3>
      <p>{app.blurb}</p>
      <p>{app.detail}</p>
    </div>
  ))}
</Section>
Enter fullscreen mode Exit fullscreen mode

SIBLING_APPS is SONACODE_APPS minus whichever app this repository builds, which is the one line that differs between the four copies:

/** 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);
Enter fullscreen mode Exit fullscreen mode

So adding a fifth app is one entry in one array, and it appears in the markup and on the page of all four sites at once. There is no way to be listed in the graph and missing from the page, because they read the same array.

The section's opening paragraph is generated, and it says the unflattering thing on purpose:

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.`;
Enter fullscreen mode Exit fullscreen mode

Writing "our family of complementary products" there would have been a lie that a reader can check in one click. A visitor who arrived at the rental app and is being shown a pub quiz will believe an honest sentence and bounce off a dishonest one. So each app carries a detail field written for exactly that reader:

/**
 * The context a one-liner cannot carry: the problem the app is for, what it
 * actually does about it, and the thing that makes it different. Written to be
 * read by somebody who arrived from one of the other three apps and has no
 * reason yet to care about this one.
 */
detail: string;
Enter fullscreen mode Exit fullscreen mode

One incidental thing I liked: APP_COUNT is spelled out in words, because "Sonacode builds 4 apps" reads like a typo in a sentence and "four" does not.

const COUNT_IN_WORDS = ["no", "one", "two", "three", "four", "five", ...];
const APP_COUNT = COUNT_IN_WORDS[SONACODE_APPS.length] ?? String(SONACODE_APPS.length);
Enter fullscreen mode Exit fullscreen mode

Four copies, kept in agreement by hand

There is no shared npm package. The file lives in four repositories and its header tells you what to do about that:

/**
 * 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.
 */
Enter fullscreen mode Exit fullscreen mode

A private package would be the textbook answer, and at a larger scale it would be right. At this one it would mean a registry, a version bump, four dependency updates and four redeploys for a file that changes when an app is added or renamed, which so far is twice. The manual copy is a worse mechanism with a better failure mode: if I forget the third repository, that site is temporarily not in the graph, which is where it was before any of this existed.

The safeguard is not a build step, it is the sentence "change all four in the same sitting" being the first thing you read when you open the file, and a live check you can run in a console in ten seconds. For a four-node graph that changes twice a year, that has been enough.

Top comments (0)