Skip to main content

Defining JSON-LD Structured Data

JSON-LD (JavaScript Object Notation for Linked Data) is a small block of JSON we put inside a <script type="application/ld+json"> tag. It describes the page in a way machines understand: "this page is a job posting", "this site is run by this organisation".

Structured data is the general name for this. schema.org is the shared dictionary of types (Organization, WebSite, JobPosting) that Google and other engines read.

We do this for two reasons:

  • SEO (Search Engine Optimisation) — help Google show rich results (the job card, the brand knowledge panel).
  • AEO (Answer Engine Optimisation) — help AI answer engines (the assistants that answer questions directly) understand who Jod is and ground their answers on us.

This doc is about how to define the JSON-LD correctly. For how React Router loads it into the <head> (the meta function and the script:ld+json descriptor), read the sibling doc Loading jsonLD in React Router first — this doc assumes you know that mechanism.


Why This Doc Exists — Real Problems In Our Own Code

We are adding sitewide structured data for the JodRewards / SEO+AEO work. While reviewing it, we found real problems. Every rule below comes from one of these. Read the problem first, then the rule.

  1. The home page had no Organization or WebSite schema. The home page is the single most important page for our brand entity, and it described nothing about who Jod is.
  2. Our brand name is written three different ways. home.jsx:19 says Jod Board. listings-job-service.js:23 says Jodapp. The new schema says Jod. To a machine these are three different organisations, not one brand with typos.
  3. The "obvious" way to add the sitewide schema would silently fail on the home page. More on this below — it is the most important nuance in this doc.
  4. The apex domain https://jodapp.com is written by hand in three places (the root layout, the meta helper, robots.txt). Three copies drift apart over time.
  5. The Organization had no logo and no sameAs — the two fields Google actually uses to build the brand knowledge panel and to confirm "this Jod is that Jod".

Mental Model — JSON-LD Is A Graph

A graph here does not mean a chart. It means the computer-science kind: nodes (things) joined by edges (relationships). JSON-LD stands for Linking Data — the point is that your data is not a flat list of facts, it is a web of entities pointing at each other.

On one job detail page, the nodes and edges look like this:

  Organization ──── publisher ───────▶  WebSite
"Jod" "Jod"
(who runs this site)

JobPosting ──── hiringOrganization ──▶ Organization
"Cashier role" "FairPrice"
(who is hiring for THIS job)

Four nodes, two edges. publisher and hiringOrganization are the edges.

The Database Analogy

You already think in this model. It is just normalisation:

schema.org graphdatabase
a node (Organization, WebSite)a row
@id (https://jodapp.com/#organization)the primary key
{ "@id": "..." } referencea foreign key
graph consistencyreferential integrity + not denormalising

@id is the primary key of a node. { "@id": "#organization" } is a foreign key that points at it. So instead of copying the whole "Jod" object every time you mention it, you point at it by id — the same way you would put a foreign key to one companies row instead of copying the company name into every job row.

Graph consistency means: the same real-world thing is one node everywhere, not copied into several near-identical nodes that drift apart. This is why problem #2 (three brand names) is serious — in a graph, an inconsistent name is not a typo, it is two entities.

The principle: graph consistency is "do not denormalise" applied to structured data. One entity, one identity, referenced everywhere — not copied with drift.


Two Kinds Of Schema — Global vs Per-Page

There are two kinds of structured data on our site, and they live in different places.

KindExampleDescribesWhere it renders
GlobalOrganization, WebSiteThe publisher of the site (Jod)The root layout (root.jsx)
Per-pageJobPosting, FAQPageThe one thing on that pageThe route's meta export

The two Organizations on a job page are not in conflict:

  • JobPosting.hiringOrganization = the employer offering that one job (FairPrice). This is required by Google and must never be Jod. If you set it to Jod, you are telling Google that Jod is the employer for every job, which is false — Google's structured-data policy treats employer misrepresentation as a violation and can drop the job rich result.
  • The sitewide Organization = Jod, the company that runs the website. This is the publisher entity that powers Jod's own knowledge panel. It does not feed the job card — the job card reads hiringOrganization, so FairPrice's job still shows FairPrice.

The principle: model the role, not the name. "Who runs this site" and "who is hiring for this listing" are two different questions. Give each its own node, and the shared type Organization stops being confusing.


The Most Important Nuance — Why Global Schema Goes In The Layout, Not meta

This is problem #3, and it is the part engineers get wrong.

The sibling doc teaches the React Router way: return { "script:ld+json": schema } from a route's meta export. That is correct for per-page schema like JobPosting. It is wrong for global schema like Organization. Here is why.

React Router does not merge meta across nested routes. The deepest matched route that exports a meta function wins, and its return value replaces everything its parents returned.

Our home page exports its own meta:

// app/routes/home.jsx
export const meta = () => ([
{ title: 'Jod Board - Part-time, Gig & Blue Collar Jobs in Singapore' },
{ name: 'description', content: '...' }
])

So if you put the global Organization schema in a meta export on root.jsx:

// BAD — root.jsx meta export
export const meta = () => ([
{ 'script:ld+json': organizationSchema } // global, every page... we hope
])

…then on the home page, home.jsx's meta replaces root.jsx's meta, and the Organization schema disappears — on the exact page where it matters most. The same happens on the ~60 other public routes that export their own meta (job detail, rewards, about, and so on). The bug is silent: the page still renders, the schema is just gone.

The fix is to render global schema in the Layout component of root.jsx. A component renders on every route, no matter what each route's meta does. That is why we accept a raw <script> tag there.

// GOOD — app/root.jsx, inside the Layout component's <head>
{ !isTeamRoute && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(SITE_SCHEMA) }}
/>
) }

The principle: per-page schema goes in the route's meta export; global, every-page schema goes in the root Layout component. If a tag must appear on every page, a meta export cannot guarantee it — a child route will override it.


dangerouslySetInnerHTML — Why It Is Used And When It Is Safe

The name is frightening, but here it is the correct tool, and there is no safer one being skipped.

  • Why it is needed. A <script> tag holds text. React does not let you put a JavaScript object as the children of a tag — dangerouslySetInnerHTML is the only way React sets a script tag's text content.
  • Why it is safe in our case. The danger of dangerouslySetInnerHTML is XSS (Cross-Site Scripting — running attacker code in the user's browser). XSS only happens with untrusted input. Our schema is built from hardcoded constants ("Jod", "https://jodapp.com"). There is no user input, so there is no XSS.
  • React Router uses the exact same thing. When you use { "script:ld+json": schema } in a meta export, React Router's <Meta /> renders it as dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} — byte for byte what we wrote by hand. So we are not avoiding a safer API; there isn't one.

The one rule you must not break: JSON.stringify does not make a string safe for HTML. It does not escape the characters </script>. The moment any dynamic or user-controlled value flows into a schema, a value containing the text </script> would end the script tag early — both a broken page and an injection hole. So:

// Static, trusted values only. If you ever put dynamic/user data in here,
// JSON.stringify will NOT stop a </script> breakout — escape '<' first.

The principle: dangerouslySetInnerHTML with static constants is safe; the safety guarantee is "the data is trusted", not the function. Mark the values trusted in a comment so the next person does not paste dynamic data into the same block.


Brand Entity Consistency — One Name, Everywhere

This is problem #2, and it is the highest-leverage fix.

A machine compares the exact string. Jod, Jod Board, and Jodapp are not three spellings of one brand — they are three entities. The job is to pick one canonical name and use it everywhere, so the engines consolidate us into a single brand.

// bad — three different names across the site
'Jod Board - Part-time...' // home.jsx
'... | Jodapp' // listings-job-service.js
{ name: 'Jod' } // root.jsx schema

// good — one canonical name; variants and legal name have their own fields
{
name : 'Jod', // the one brand name, used in every title and schema
alternateName : 'Jod Board', // a variant we still want to claim
legalName : 'Jod ... Pte. Ltd.' // the registered company (allowed to differ)
}
  • name — the one canonical brand name. The new schema's name: 'Jod' is correct; the ~47 titles should be changed to match it, not the other way around.
  • alternateName — the right home for variants you still want to claim, without splitting the main name. It can be a list: alternateName: ['Jod Board', 'Jodapp'].
  • legalName — the registered company. This may differ from name, and that is fine. Do not force it to match.
  • Jodapp is not the name, but it is a real alternateName. jodapp.com is our address (domain), and people do search "Jodapp" — so keep it out of name and out of <title> suffixes (those stay "Jod"), but claim it in alternateName. Dropping it entirely throws away a real search signal; using it as the brand label fragments the entity. alternateName is the middle path.

Jod or JOD? Use "Jod"

Three reasons, strongest first:

  1. "JOD" is the ISO 4217 currency code for the Jordanian Dinar (ISO 4217 is the international standard list of currency codes). For a Singapore jobs marketplace that talks about pay and wages, an all-capitals token that is literally a global currency code is a self-made confusion problem for Google's Knowledge Graph and for AI answer engines. Title-case "Jod" reads as a proper noun and avoids it.
  2. All-capitals is for true initialisms — names you spell letter by letter (IBM, NASA). "Jod" is pronounced as a word, like Ikea or Asos, so it is title case. Writing "JOD" signals "this is an acronym", which it is not.
  3. Letter case is not a ranking signal (see the table below), so there is no SEO cost to either form. That means the choice is decided purely on clarity — and clarity says "Jod".

Is SEO Case-Sensitive?

SurfaceCase-sensitive?Status
Ranking / query matchingNo. Google folds case; jod = Jod = JOD is one token.Google-documented fact
URLsPath and query: yes. Domain: no. But that is slugs, not a brand name. Keep paths lowercase.Google-documented fact
Title shown in search resultsGoogle can rewrite the title; the docs say nothing about case. Low stakes for ranking, matters for click-through and brand perception.Rewrite = fact; rest = opinion
Structured-data name / WebSite.nameA literal string, surfaced as your brand. Google: "use the same name and alternateName as your site name.""Use the same name" = fact
AI answer engines (AEO)No case rule. They ground on a consistent name plus sameAs profiles.Best-practice opinion

In short: for ranking, case does nothing. For the brand entity, case is not the lever — consistency of the exact name string is. The only reason case matters at all is that "JOD" walks into the Jordanian-Dinar namespace.

Where The Name Must Match (Consistency Checklist)

Pick name = "Jod" and make these identical:

  • Page <title> brand suffix on every page → ... | Jod (fix home.jsx, listings-job-service.js, and the rest)
  • Organization.name and WebSite.name in the sitewide JSON-LD → "Jod"
  • The rendered logo / wordmark casing → "Jod"
  • sameAs official profiles (LinkedIn, Crunchbase, social) → the display name reads "Jod"
  • alternateName → claim every real variant here, as a list: ['Jod Board', 'Jodapp']
  • legalName → the registered entity (may differ)
  • Never use Jodapp as the name or a <title> suffix — but DO claim it in alternateName

The principle: one canonical name, referenced identically everywhere. This is the same "do not denormalise" rule, now applied to the brand string itself.


What Google Documents vs What Is Community Best Practice

Ali keeps asking "whose recommendation is this?" — because the answer changes how much weight to give it. Keep these two columns separate.

Google actually documents these (use them)

For Organization, Google's own page lists name, alternateName, legalName, url, logo, sameAs, contactPoint, and more. The two that do the heavy work for our brand entity:

  • logo — Google uses it in the knowledge panel. An Organization with no logo gives the panel nothing.
  • sameAs — links to your official profiles (LinkedIn, Wikidata, social). This is Google's documented way to confirm "this Jod is that Jod". It is the real entity-reconciliation signal — not @id.
const ORGANIZATION_SCHEMA = {
'@context' : 'https://schema.org',
'@type' : 'Organization',
'name' : 'Jod',
'url' : APEX_URL,
'logo' : 'https://jodapp.com/<logo>.png',
'sameAs' : [
'https://www.linkedin.com/company/...',
'https://www.instagram.com/...'
]
}

sameAs must be official profile pages — LinkedIn, Instagram, Facebook, TikTok, Wikidata. Not a chat-group invite (a Telegram or WhatsApp link is a chat, not a profile), and not a mailto:. Also: don't build sameAs by filtering a presentational list of social links by label — that is fragile, because a new non-profile entry leaks in silently. List the profile URLs you want, or put an explicit flag on each one.

Community best practice — @id and @graph (optional)

The @id + @graph pattern (give each node a stable id, link them, e.g. WebSite.publisherOrganization) is strongly recommended by the schema community — most of all by Yoast, the most-installed SEO plugin, which ships it by default. Google's Organization doc never lists @id as a property — but Google's structured-data policies do document using @id to relate two separate items on a page. So the linking mechanism is Google-documented; the @id property on Organization is community convention. Both are true. So:

  • It is graph hygiene: it stops 60 copies of the Jod Organization from looking like 60 different organisations — they all share one @id, so they are understood as one entity.
  • It helps AEO: AI answer engines ground better on a linked graph than on disconnected facts.
  • It is not a Google ranking requirement. If someone says "Google requires @id", that is wrong. If they say "the schema community strongly recommends it, Yoast ships it by default, and Google documents @id for relating items", that is right.

An @id is only useful if something references it. A node with an @id that nothing points to is dead weight — a primary key with no foreign key. So if you give the Organization an @id, the WebSite must (a) carry its own @id and (b) point at the Organization with publisher. Half a graph is just two objects with extra keys.

The "do it properly" version puts the two sitewide nodes in one @graph block, linked by @id:

const SITE_SCHEMA = {
'@context' : 'https://schema.org',
'@graph' : [
{
'@type' : 'Organization',
'@id' : 'https://jodapp.com/#organization', // stable id, deduped sitewide
'name' : 'Jod',
'url' : APEX_URL,
'logo' : 'https://jodapp.com/<logo>.png',
'sameAs' : [ 'https://www.linkedin.com/company/...', '...' ]
},
{
'@type' : 'WebSite',
'@id' : 'https://jodapp.com/#website',
'name' : 'Jod',
'url' : APEX_URL,
'publisher' : { '@id': 'https://jodapp.com/#organization' } // "Jod publishes this WebSite"
}
]
}

The per-page JobPosting stays its own separate <script> block. Because nodes with the same @id merge across blocks, the publisher Jod and the JobPosting.hiringOrganization (FairPrice) stay cleanly distinct.

Two separate <script> blocks — one for the global Organization + WebSite, one per per-page schema — is fine and documented: Google reads every ld+json block on a page and merges nodes by @id. You do not need to combine them into one page-wide @graph, and you could not easily anyway — the global pair renders in Layout while per-page schema renders in the route's meta export.

SearchAction / Sitelinks Search Box

Older guides add a potentialAction / SearchAction to WebSite. Don't. Google retired the Sitelinks Search Box on 2024-11-21, so that markup now produces no result. Keep the WebSite node anyway — it still feeds Google's site-name feature.

The principle: build to the documented spec (logo, sameAs) first; treat @id / @graph as optional polish for graph consistency and AEO. Always name whose convention you are following.


One Source Of Truth For The Apex Domain

Problem #4: https://jodapp.com is hand-written in the root layout, the meta helper, and robots.txt. When we move to a regional apex or add an environment split, someone updates two of three and the canonical link and sitemap disagree — a bug that stays invisible until Google Search Console complains.

Keep the apex domain in one constant and import it. Build the schema objects from that constant at module scope (the top of the file, next to links), not inside the Layout function — they are constants, they do not depend on render, so they should not be rebuilt on every navigation.

// good — one apex constant, schema built once at module scope
import { APEX_URL } from '@/utils/constants'

const SITE_SCHEMA = { /* ...uses APEX_URL... */ }

export function Layout({ children }) {
// render SITE_SCHEMA, do not define it here
}

Pick the host that returns 200, not one that redirects. https://jodapp.com serves 200; https://www.jodapp.com returns a 301 to it. So the apex constant, the canonical links, the sitemap, and Organization.url / @id must all use the bare jodapp.com. Pointing url or @id at a host that 301-redirects makes Google follow an extra hop to reconcile the entity. (Some legal links in constants.js still use www. — move those to the bare apex too.)

The principle: the apex domain is one fact — it lives in one place. Canonical links, sitemap, and schema all read from the same constant — and that host returns 200, not a redirect.


Checklist For Adding New Structured Data

  • Is this schema global (every page) or per-page?
    • Global → render in the root.jsx Layout component.
    • Per-page → return { 'script:ld+json': schema } from the route's meta export.
  • Does the brand name exactly match our canonical "Jod" everywhere, with variants claimed in alternateName (['Jod Board', 'Jodapp'])?
  • Does the Organization have logo (≥ 112×112, crawlable) and sameAs (profile pages only — no chat invites)?
  • If the Organization has an @id, does the WebSite have its own @id and a publisher pointing at it? (an @id nothing references is dead weight)
  • Does the apex host return 200 (not a 301 redirect)?
  • Are all values static and trusted? If any value is dynamic, did you handle the </script> escaping risk?
  • Does the apex domain come from the shared constant, not a hand-typed string?
  • For a JobPosting, is hiringOrganization the employer, never Jod?
  • Did you test the page in Google's Rich Results Test and view-source to confirm the <script type="application/ld+json"> is present?

References

Google (the spec):

React Router:

Community best practice (not Google):

Standards referenced: