Blog· HubSpot technical SEO 9 min read

How to Add JSON-LD Schema to a HubSpot Website: A Technical Implementation Guide

The implementation half of AI search visibility: build one Organization entity with a stable @id, then have every Service, FAQPage and BlogPosting node reference it from a HubL template — not from pasted page-level blocks.

How to Add JSON-LD Schema to a HubSpot Website: A Technical Implementation Guide — Hubstack HubSpot article cover
In this article
  • Step 1: one Organization entity, one permanent @id
  • Step 2: make the values dynamic, not hardcoded
  • Step 3: Service schema on every service template
  • Step 4: FAQPage generated from the visible FAQ module
  • Step 5: BlogPosting with dates that are actually true
  • Common mistakes that quietly void the whole implementation
  • How to verify it, in order
  • What good looks like when you are done

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.

partials/schema-organization.html — included from the base layout
<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.

HubL-driven Organization fields
{% 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.

templates/service.html
<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.

modules/faq/module.html — schema plus markup from one field
<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.

templates/blog-post.html
<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, symptom, fix
MistakeWhat you seeFix
Two Organization nodesRich Results Test shows the entity twice with different @id valuesDelete the theme-injected block, keep one global partial
Orphaned Service nodeValid schema, no entity association in Search ConsoleAdd provider referencing the Organization @id
Schema-only FAQFAQ text in JSON-LD but not in HTMLGenerate schema and markup from the same repeater field
Frozen dateModifiedPosts updated this year still dated at first publishBind to content.updated, not a literal
Unescaped apostropheBlock disappears entirely from parsed outputApply |striptags|escapejs to every editor field
Client-injected JSON-LDPresent in browser DevTools, absent in view-sourceMove 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.

Quick command-line check for duplicate Organization nodes
curl -s https://yourdomain.com/ | grep -c '"@type": *"Organization"'
curl -s https://yourdomain.com/ | grep -o '"@id": *"[^"]*#organization"' | sort -u

What 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.

FAQ

Questions people actually ask AI about this.

How do I add JSON-LD schema to a HubSpot website?

Add it in the HubL template, not the page editor. Create a global partial containing one Organization node with a permanent @id such as https://yourdomain.com/#organization, include that partial from your base layout so it renders in the head of every page, then add type-specific nodes — Service, FAQPage, BlogPosting — in the relevant templates, each referencing the Organization @id. Bind all values to HubL variables like content.absolute_url and content.updated so they cannot drift from the page.

Why does schema need an @id in HubSpot?

The @id is a permanent identifier that lets separate schema nodes reference the same entity instead of each redescribing it. Without it, a crawler treats your homepage Organization, your service page provider and your blog post publisher as three unrelated companies with similar names. With one @id referenced everywhere, fifty pages resolve into a single connected entity graph — which is what answer engines use to decide whether you are a recognised source.

Does HubSpot add schema markup automatically?

Only partially, and not the parts that matter. HubSpot outputs some basic blog metadata and Open Graph tags, but it does not generate a canonical Organization entity, Service nodes, FAQPage markup or a connected graph. Marketplace themes sometimes inject their own Organization block, which is a common source of duplicate entities — check your published HTML before adding yours.

Can I put FAQ schema on a page without showing the questions?

No. FAQPage markup requires every question and answer to be visible to the visitor on that page; content hidden behind anything more than an accordion expand is a guidelines violation, and it is easy to detect because the text exists in your JSON-LD and nowhere in your HTML. Generate the schema from the same repeater field that renders the visible FAQ so the two cannot disagree.

How do I check whether my HubSpot schema is working?

Start with view-source rather than DevTools, so you see what a crawler receives instead of the hydrated DOM. Confirm exactly one Organization node per page and one identical @id string across the site. Run Google's Rich Results Test once per template type, then the schema.org validator to confirm references resolve rather than leaving dangling @id values. Finally, read the JSON-LD next to the rendered page to confirm every claim is visible on it.

Why did my JSON-LD block disappear from the page?

Almost always invalid JSON from unescaped editor input. A curly apostrophe, an ampersand or a line break coming out of a rich text field breaks the block, and parsers discard the whole script rather than reading part of it. Pipe every editor-entered value through |striptags|escapejs in HubL. The second cause is JavaScript-injected schema, which appears in DevTools but never in the source a crawler fetches.

Proof

What this looks like when it's done right.

Want this implemented properly at template level instead of pasted page by page?

Related

Technical SEO services

Redirect mapping, metadata baselines, canonical configuration, schema and indexing controls — handled as an ongoing programme rather than a launch-week scramble.

HubSpot technical SEO
Keep reading

What Actually Breaks SEO During a HubSpot Migration (And How We Prevent It)

Rankings rarely drop because you moved to HubSpot. They drop because of five specific technical failures during cutover — redirects, metadata, canonicals, schema and indexing settings.

Read article