Discuss your task

How to Set Up Structured Data on a Headless Site

Structured data on a headless site is set up manually: an application/ld+json block is assembled from frontmatter or CMS data and rendered on the server together with the page. Unlike WordPress, where plugins such as Yoast generate markup automatically, a headless stack has no such layer by default - you embed it into templates yourself. Below - which markup types a B2B site needs and how to implement them in Astro and Next.js.

In WordPress-to-headless migration projects, we regularly see the same picture: a developer carries over the content, layout, and load speed, and only remembers markup once rich snippets disappear from search results. The plugin that generated organization, article, and breadcrumb markup for years stays behind in the old CMS - the new site on Astro or Next.js knows nothing about Schema.org and JSON-LD until someone adds them by hand.

Below - what structured markup actually gets you in search and in AI answers, which schema types a marketing B2B site needs, how to assemble JSON-LD from template data in Astro and Next.js, and how to verify the markup actually works.

Without structured data, a search engine still indexes the page, but gets only the headline and text - with no explicit signal that this is an article, which organization it belongs to, or what questions its FAQ answers. Google won’t show a rich snippet, AI systems like ChatGPT and Perplexity are less likely to cite the page as a source, and breadcrumbs in search results stay a plain URL instead of a readable path. For a B2B site this means a lower CTR in search results and absence from FAQ or How-to blocks, even when the content answers the question better than competitors.

Structured data (Schema.org): a standardized vocabulary of tags developed by Google, Microsoft, Yahoo, and Yandex that describes page content in a format search engines and AI agents can understand.

JSON-LD: a format for writing structured data as a separate JSON block inside a <script type="application/ld+json"> tag - Google’s recommended format, decoupled from the page’s visible markup.

What structured data gets your site in search and in AI answers

Markup doesn’t change what a visitor sees on the page - it adds a machine-readable layer alongside the regular HTML. The search engine reads that layer and decides whether to show a rich result instead of a plain blue link.

The practical effect is rich results: a star rating for reviews, an expandable question list on an FAQ page, numbered steps for a how-to, breadcrumbs instead of a URL. Google builds these blocks only from data marked up according to the Schema.org vocabulary - without markup, the page stays a plain link even when the content is objectively better.

The second effect concerns AI search. ChatGPT, Perplexity, and Google AI Overviews assemble answers from page fragments, and an explicitly marked-up fact - author, publication date, question-answer structure - gets extracted more accurately and is more likely to end up as a cited source than the same fact dissolved in running text.

Why a headless platform doesn’t set up markup on its own

On WordPress, markup is almost always generated by an SEO plugin: it reads the title, author, and publication date from the database and assembles JSON-LD itself on every page render. The developer writes nothing by hand - which is exactly why a team migrating to a headless stack often doesn’t think about this step until release.

In a headless architecture, there’s no such automatic layer - not in Astro, not in Next.js, not in other SSR and SSG frameworks. Both frameworks hand back HTML and data, and it’s up to the team to decide what markup goes into <head>. This isn’t an architectural shortcoming, it’s a direct consequence of the fact that a headless stack doesn’t dictate where the article’s title and date come from: a Markdown frontmatter, a headless CMS, or a database - in every case, you have to assemble the data schema yourself.

The site already runs into the same pattern when setting up hreflang for a multilingual version: WordPress plugins like WPML collect alternate language versions automatically, while on a headless stack the developer writes the tags by hand in every template. Structured data is the same class of task - an SEO layer that used to be hidden inside a plugin now becomes part of the site’s code.

Which markup types a B2B site needs

Not every schema type in the Schema.org catalog is equally useful for a marketing site. For a B2B project on a headless stack, six types carry practical value:

  • Organization - company name, logo, social links, and contact details. Set once globally, usually in a shared layout component, and feeds Google’s Knowledge Panel.
  • BreadcrumbList - the page’s hierarchy relative to the site section. Turns the URL in search results into a readable chain of sections and makes it easier for crawlers to understand the site structure.
  • Article or BlogPosting - title, author, and publication and update dates. The main type for a blog and case studies, mandatory on every post.
  • FAQPage - a list of questions and answers on the page. Produces an expandable block right in search results and is one of the most reliable sources of citation in AI search.
  • HowTo - a step-by-step instruction with explicitly listed steps. Fits guides and tutorials that have a sequence of concrete actions.
  • Product - relevant only for e-commerce sites: price, availability, product rating. A marketing B2B site without a product catalog usually doesn’t need this type.

You don’t need to put all six types on one page at once - the schema should reflect the actual content type: a blog post gets BlogPosting plus BreadcrumbList, a Q&A page gets FAQPage, a guide with steps gets HowTo.

How to implement JSON-LD in Astro

In Astro it’s convenient to move the markup into one reusable component that takes a data object and renders it as a <script> block. The schema itself is assembled from the same data that’s already in the article’s frontmatter or in Content Collections - you don’t have to duplicate the data by hand.

JsonLd.astro component
---
interface Props {
  data: Record<string, unknown>;
}
const { data } = Astro.props;
---
<script type="application/ld+json" set:html={JSON.stringify(data)} />

Using the component in a blog post template looks like assembling a plain object from fields that already exist in the frontmatter:

Example for a blog post template
---
import JsonLd from '../components/JsonLd.astro';
const { title, publishedAt, updatedAt, description } = Astro.props;

const articleSchema = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: title,
  datePublished: publishedAt,
  dateModified: updatedAt,
  description,
  author: {
    '@type': 'Organization',
    name: 'Exceltic.dev',
  },
};
---
<JsonLd data={articleSchema} />

The same principle scales to FAQPage: the questions and answers already exist in the article text as ### Question? headings with the answer right below them, and the schema is assembled by parsing that same section at build time - no separate data source to maintain by hand.

In a typical WordPress-to-Astro migration project for a 30-50 page B2B site, a markup component covering Organization, BreadcrumbList, and BlogPosting takes a developer 3-5 hours to build, including wiring it up to existing frontmatter data. A separate task is rolling out FAQPage and HowTo wherever the content actually has questions or step-by-step instructions - that doesn’t need a new component, just a different set of input fields.

How to carry the same approach over to Next.js

The principle in Next.js is identical: the schema is assembled as a plain JavaScript object and rendered inside a <script type="application/ld+json"> via dangerouslySetInnerHTML. The only difference is component syntax - React instead of an .astro file.

ArticleJsonLd component for App Router
export function ArticleJsonLd({ post }: { post: Post }) {
  const schema = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    description: post.description,
  };

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
    />
  );
}

The component is wired into layout.tsx or directly into page.tsx alongside the built-in Metadata API call, which handles the title and meta tags but doesn’t generate JSON-LD on its own - the Metadata API doesn’t cover schema markup, so it’s always added as a separate component. If the team is still choosing between frameworks, a breakdown of how data rendering differs between Astro and Next.js overall is in Astro Islands vs Next.js Server Components.

How to verify the markup works

JSON-LD validity is worth checking before release, not after a rich snippet fails to show up in search results. Google provides the Rich Results Test for this: it shows which markup types Google recognized on the page and which fields it considers required but missing.

The second tool is the Schema Markup Validator, a Google-independent validator that checks the markup against the Schema.org vocabulary itself, not just the subset of types a particular search engine supports. Both tools accept either a live URL or manually pasted HTML - useful for checking a page before deploy, straight from a local build.

After the first markup deploy, it’s worth returning to the Rich Results Test in one or two weeks: Google doesn’t show a rich snippet the instant a valid schema appears - indexing and re-crawling the page take time.

Common mistakes when implementing markup

Three mistakes show up on headless projects more often than the rest, and none of the three are caught by a validator:

  • Marking up content that isn’t on the page. The schema states a rating, price, or author the user doesn’t see in the interface - a validator formally allows this, but it’s a direct violation of the structured data guidelines that manual actions get applied for.
  • Duplicate or conflicting markup. The same page gets two conflicting BlogPosting blocks, for example during a migration when the old component wasn’t removed and the new one was already added - Google may ignore both in that case.
  • Stale schema after a content edit. The update date, title, or author in the JSON-LD doesn’t match the text on the page, because the schema was assembled once by hand instead of from the same data as the rest of the template - assembling the schema from a single data source instead of duplicating values removes this problem entirely.

Who this matters most for

Markup has an especially noticeable effect on visibility for B2B companies with a blog and a case studies section - that’s where BlogPosting and FAQPage matter most, and competition for featured snippets and AI citation in niche B2B queries is lower than in mass consumer topics. Companies that are only just migrating from WordPress to a headless stack should build markup into the migration plan from the start, not add it as a separate sprint after release - that way there’s no dip in search visibility during the transition at all.

Frequently asked questions

What is JSON-LD and how is it different from HTML microdata?

JSON-LD is a separate JSON block inside a <script type="application/ld+json"> tag that isn’t embedded in the page’s visible markup. Microdata (itemprop, itemscope) is added directly into the HTML tags of the content and requires editing the page markup itself on every schema change. Google officially recommends JSON-LD specifically because it’s easier to generate programmatically and doesn’t break the layout on updates.

Does a small site with a dozen pages need Schema.org markup?

Yes, but in limited scope. Even a small site should add Organization on the homepage and BreadcrumbList on inner pages - that’s the minimum set that doesn’t require maintaining data beyond what’s already in the template. FAQPage and HowTo get added selectively, wherever a page actually has questions or a step-by-step instruction, not across every page.

How often does structured data need to be updated?

The markup doesn’t need a separate manual update if it’s assembled programmatically from the same data as the rest of the page content - the update date, title, or author change once at the data source and automatically flow into the schema on the next build. The schema needs a manual update only on projects where JSON-LD is hardcoded statically right in the template instead of being assembled from frontmatter or a CMS.

Can multiple schema types be used on the same page?

Yes, and for a blog post it’s standard practice: BlogPosting and BreadcrumbList on the same page don’t conflict, because they describe different aspects of the page - the content and its place in the site structure. The problem comes not from the number of types but from duplicating the same schema type with two independent blocks of code.

Does Schema.org directly affect a site’s search rankings?

Directly - no: Google has repeatedly confirmed that valid markup by itself doesn’t move a page up in rankings. The indirect effect is more noticeable: a rich snippet with a rating or an FAQ block increases click-through rate from search results, and a more precise machine-readable description of the content raises the odds of landing in cited AI search sources - both effects ultimately show up in traffic.

If you’re moving from WordPress to a headless stack and don’t want to lose rich snippets during the transition - build structured data into the migration plan upfront, not as a separate sprint after release. Three things are worth doing first: assemble Organization and BreadcrumbList into a shared layout component, wire up BlogPosting and FAQPage to data that’s already in your article templates, and check both schemas with the Rich Results Test before deploy, not after.

If you’re currently designing the architecture for a site built from scratch or already migrated to a headless stack without markup - describe the task to the Exceltic.dev team. We’ll figure out which schema types your content actually needs and wire them into your existing templates.

More articles

All →