This is the build guide. If you want the reasoning behind it — why answer engines read structured data differently from Googlebot, and what happens when your copy lives inside client-rendered modules — read Why Your HubSpot Website Might Be Invisible to AI Search first. This post assumes you already agree and just want the code.
The architecture is one sentence: publish a single canonical Organization entity at a permanent @id, then make every other schema node on the site point at that @id instead of redescribing your company. Do that and a crawler resolves fifty pages into one connected graph. Skip it and you have fifty unrelated companies that happen to share a name.
Everything below goes into HubSpot CMS templates, not into individual page editors. Template-level schema is the only version that survives a content team, and in our audits it is the single biggest difference between sites whose structured data stays accurate and sites where a third of pages contradict themselves.
Step 1: one Organization entity, one permanent @id
Put the Organization node in a global partial included by every template — in HubSpot that means a module or partial referenced from your base layout, so it renders once per page in the head.
The @id is the important part. It is a permanent identifier, not a URL you visit: pick https://yourdomain.com/#organization and never change it. Every Service, Article, FAQPage and BlogPosting node on the site will reference that exact string, which is how a crawler knows the publisher of a blog post and the provider of a service are the same entity.
Only include aggregateRating if you have real, verifiable reviews you can point to on a public profile. Inventing that block is the fastest way to get structured data ignored across the whole domain.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://yourdomain.com/#organization",
"name": "Your Company",
"url": "https://yourdomain.com",
"logo": {
"@type": "ImageObject",
"url": "https://yourdomain.com/hubfs/logo-512.png",
"width": 512,
"height": 512
},
"description": "One sentence that matches the description in your meta tag.",
"sameAs": [
"https://www.linkedin.com/company/your-company",
"https://www.g2.com/products/your-company"
]
},
{
"@type": "WebSite",
"@id": "https://yourdomain.com/#website",
"url": "https://yourdomain.com",
"name": "Your Company",
"publisher": { "@id": "https://yourdomain.com/#organization" },
"inLanguage": "en"
}
]
}
</script>Step 2: make the values dynamic, not hardcoded
Hardcoded strings drift. The moment marketing changes the company description in one place, your schema is describing a company that no longer exists. Bind the fields to HubSpot settings or a HubDB row so there is exactly one place to edit them.
Use content.absolute_url for page-level URLs and site_settings or a module field for organisation-level values. The |escapejs filter matters: an unescaped apostrophe or line break in an editor-entered field produces invalid JSON, and invalid JSON means the entire block is discarded — not partially read.
{% set org = hubdb_table_rows(1234567)[0] %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"@id": "{{ org.site_url }}/#organization",
"name": "{{ org.company_name|escapejs }}",
"url": "{{ org.site_url }}",
"description": "{{ org.description|escapejs }}",
"sameAs": [
{% for profile in org.profiles %}
"{{ profile.url }}"{% if not loop.last %},{% endif %}
{% endfor %}
]
}
</script>Step 3: Service schema on every service template
One Service node per service page, rendered from the service template so all of them are structurally identical. provider references the Organization @id — do not restate the company name, address or logo inside the Service node.
areaServed and offers are worth including when they are honest. If you publish a price, publish the same number the page shows: a mismatch between offers.price and the visible price is treated as a contradiction, and contradictions get the whole node dropped rather than corrected.
Give each Service its own @id derived from the page URL. That makes it individually citable and lets you reference it later from a BlogPosting via mentions or about.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Service",
"@id": "{{ content.absolute_url }}#service",
"name": "{{ module.service_name|escapejs }}",
"serviceType": "{{ module.service_type|escapejs }}",
"description": "{{ module.service_summary|escapejs }}",
"url": "{{ content.absolute_url }}",
"provider": { "@id": "https://yourdomain.com/#organization" },
"areaServed": { "@type": "Place", "name": "Worldwide" },
"offers": {
"@type": "Offer",
"priceCurrency": "USD",
"price": "{{ module.starting_price }}",
"availability": "https://schema.org/InStock",
"url": "{{ content.absolute_url }}"
},
"isPartOf": { "@id": "https://yourdomain.com/#website" }
}
</script>Step 4: FAQPage generated from the visible FAQ module
The rule that breaks most FAQPage implementations: every question and answer in the markup must be visible on the page without interaction beyond expanding an accordion. Schema-only FAQs are a violation, and they are trivially detectable because the text appears in your JSON-LD and nowhere in your HTML.
So generate the schema from the same repeater field that renders the accordion. One loop, one source of truth, no possibility of drift.
Add a speakable specification pointing at the FAQ selectors when you want answer engines to lift the text verbatim, and give each Question its own @id so individual answers are addressable.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"@id": "{{ content.absolute_url }}#faq",
"url": "{{ content.absolute_url }}#faq",
"isPartOf": { "@id": "https://yourdomain.com/#website" },
"publisher": { "@id": "https://yourdomain.com/#organization" },
"speakable": {
"@type": "SpeakableSpecification",
"cssSelector": ["#faq h3", "#faq p"]
},
"mainEntity": [
{% for item in module.faqs %}
{
"@type": "Question",
"@id": "{{ content.absolute_url }}#faq-{{ loop.index }}",
"position": {{ loop.index }},
"name": "{{ item.question|escapejs }}",
"answerCount": 1,
"acceptedAnswer": {
"@type": "Answer",
"text": "{{ item.answer|striptags|escapejs }}",
"author": { "@id": "https://yourdomain.com/#organization" }
}
}{% if not loop.last %},{% endif %}
{% endfor %}
]
}
</script>
<section id="faq">
{% for item in module.faqs %}
<h3>{{ item.question }}</h3>
<p>{{ item.answer|striptags }}</p>
{% endfor %}
</section>Step 5: BlogPosting with dates that are actually true
Blog templates get BlogPosting, and the two fields that matter most are the ones people fake: datePublished and dateModified. Bind them to content.publish_date_localized and content.updated, formatted as ISO 8601. A dateModified that never changes tells an answer engine your content is stale; a dateModified that updates on every deploy without content changes teaches it to ignore the field.
author and publisher both reference the Organization @id unless you maintain real Person entities with their own pages — in which case give each author a Person node with its own permanent @id and reference that.
isPartOf ties the post to the blog listing, mainEntityOfPage ties it to the URL, and mentions is where you link a post to the Service it supports. That last reference is what turns a blog into a connected topic cluster rather than a pile of articles.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"@id": "{{ content.absolute_url }}#post",
"headline": "{{ content.name|escapejs }}",
"description": "{{ content.meta_description|escapejs }}",
"url": "{{ content.absolute_url }}",
"mainEntityOfPage": { "@type": "WebPage", "@id": "{{ content.absolute_url }}" },
"datePublished": "{{ content.publish_date_localized|datetimeformat('%Y-%m-%d') }}",
"dateModified": "{{ content.updated|datetimeformat('%Y-%m-%d') }}",
"image": {
"@type": "ImageObject",
"url": "{{ content.featured_image }}",
"width": 1200,
"height": 630
},
"articleSection": "{{ content.topic_list[0].name|escapejs }}",
"wordCount": {{ content.post_body|striptags|wordcount }},
"inLanguage": "en",
"author": { "@id": "https://yourdomain.com/#organization" },
"publisher": { "@id": "https://yourdomain.com/#organization" },
"isPartOf": { "@id": "https://yourdomain.com/blog#blog" },
"mentions": { "@id": "https://yourdomain.com/services/your-service#service" }
}
</script>Common mistakes that quietly void the whole implementation
Duplicate entities. Two Organization nodes with different @id values — usually one in a theme setting and one pasted into a page's head HTML — split your entity in half. Search your published HTML for "Organization" before you assume you have one. Nine times out of ten a marketplace theme already injects one.
Orphaned schema. A Service or FAQPage node with no reference to the Organization @id is a fact with no owner. It parses, it validates, and it contributes nothing to entity recognition. Validation passing is not the same as the graph connecting.
Mismatched content. Schema that claims things the page does not show — an FAQ answer that only exists in JSON-LD, a price the page contradicts, a review count with no visible reviews, a headline that differs from the H1. Each one is a reason to distrust every other statement in your markup.
Schema left behind after a migration. The old CMS's plugin generated your structured data; nobody rebuilt it in HubSpot. This is the single most common finding when we audit a migrated portal, and it is covered in detail in what actually breaks SEO during a HubSpot migration.
Invalid JSON from unescaped editor input. One curly quote, ampersand or newline from a rich text field and the parser discards the entire block silently. Always pipe editor-entered values through |escapejs, and |striptags first when the field is rich text.
Schema inside client-rendered modules. If the JSON-LD is injected by JavaScript after load, AI crawlers that do not execute JavaScript will not see it. Render it server-side in the template.
| Mistake | What you see | Fix |
|---|---|---|
| Two Organization nodes | Rich Results Test shows the entity twice with different @id values | Delete the theme-injected block, keep one global partial |
| Orphaned Service node | Valid schema, no entity association in Search Console | Add provider referencing the Organization @id |
| Schema-only FAQ | FAQ text in JSON-LD but not in HTML | Generate schema and markup from the same repeater field |
| Frozen dateModified | Posts updated this year still dated at first publish | Bind to content.updated, not a literal |
| Unescaped apostrophe | Block disappears entirely from parsed output | Apply |striptags|escapejs to every editor field |
| Client-injected JSON-LD | Present in browser DevTools, absent in view-source | Move into the HubL template head |
How to verify it, in order
Verify in this sequence, because each step catches a different failure class and the cheap checks come first.
1. View source, not DevTools. Right-click view-source or curl the URL. DevTools shows the hydrated DOM; view-source shows what a crawler receives. If your JSON-LD is only in the first one, you have a rendering problem, not a schema problem.
2. Count your entities. Search the raw HTML for "@type": "Organization" and confirm exactly one result per page. Then confirm the @id string is byte-identical everywhere it appears.
3. Google's Rich Results Test on the live URL, once per template type — homepage, service page, blog post, listing page. Testing one page tells you nothing about the other twelve templates.
4. Schema.org validator for graph structure. Rich Results Test only reports on features Google supports; the schema.org validator shows the full parsed graph, which is where you see whether your references actually resolved or produced dangling @id values.
5. Diff schema against visible copy. Take three pages and read the JSON-LD next to the rendered page. Every claim in the markup should be findable on the page. This is a manual check and it is the one that catches the failures validators cannot.
6. Search Console, two weeks later. Enhancements reports lag, so treat them as confirmation rather than diagnosis. Unparsable structured data errors point at escaping bugs in editor-entered fields.
curl -s https://yourdomain.com/ | grep -c '"@type": *"Organization"'
curl -s https://yourdomain.com/ | grep -o '"@id": *"[^"]*#organization"' | sort -uWhat good looks like when you are done
One Organization entity, referenced by every other node. One Service node per service page, generated by one template. FAQPage schema that cannot drift from the accordion because both come from the same field. BlogPosting with dates bound to real content events. No page-level pasted JSON-LD anywhere in the portal.
The test is maintenance, not launch. A schema implementation is only correct if a content editor can publish thirty pages next quarter without touching JSON-LD and every one of them comes out structurally identical to the last.
If you want this built or repaired inside your portal, that is HubSpot technical SEO work — template-level structured data, entity graph, verification, and a documented baseline you can re-check after any change.
