---
title: "One h1, title, meta description and JSON-LD on Ghost, Lovable, Next.js, Replit, Webflow, Wix and WordPress"
slug: title-meta-schema-by-platform
published: 2026-10-28T12:37:22.505723+00:00
updated: 2026-11-05T12:15:03.238512+00:00
author: "M Asif Rahman"
author_url: https://ar.bd
category: "AI Readiness"
tags: check:C3, platform:ghost, platform:lovable, platform:nextjs, platform:webflow, platform:wix, platform:wordpress, platform:replit, structured data, JSON-LD, technical SEO, AI readiness
description: "One h1, a title, a meta description and JSON-LD on seven platforms: who writes each signal, what fails on each, and the scan that proves AIScan check C3."
url: https://aiscan.site/blog/title-meta-schema-by-platform
---

One page needs one `<h1>`, a `<title>`, a meta description and a JSON-LD block, and AIScan check **C3** (structured HTML) passes only when all four are in the HTML the server returns. Who writes each of the four depends on the platform: Ghost writes three from one theme helper, Next.js writes two from the Metadata API, Lovable hands all four to an audit panel, and Replit leaves them in one `index.html` that its own SEO rating only half checks, Webflow writes three in a panel and leaves the h1 on the canvas, Wix spreads them over three surfaces, and WordPress leaves the h1 to the theme.

## TL;DR

- **The test is one scan.** `npx aiscan-cli yoursite.com`, then read the **C3** row. It prints `h1: N, title: bool, meta description: bool, JSON-LD: bool`, and only `h1: 1` with three trues is a pass.
- **Check the raw HTML, not the browser.** `curl -sL https://yoursite.com/ | grep -c '<h1'` shows what a crawler that does not run JavaScript receives.
- **JSON-LD is the signal most platforms leave to you.** Across 120 Next.js sites in our own scan history, 23 of the 39 that fail C3 fail on structured data alone (measured 9 September 2026).
- **Do not paste a second block over an automatic one.** Ghost already emits an `Article` block; a hand-written one that disagrees gives crawlers two answers.
- **Seven platforms, one job.** This guide covers Ghost, Lovable, Next.js, Replit, Webflow, Wix and WordPress, each in its own section. On WordPress, 50 of the 57 sites that fail C3 fail on the heading count alone (measured 8 September 2026), because the h1 belongs to the theme and no SEO plugin touches it.

## Contents

| Platform | Jump to | Who writes the four signals |
|---|---|---|
| Ghost | [#ghost](#ghost) | The theme: `{{meta_title}}` and `{{ghost_head}}` write title, description and `Article` JSON-LD; the h1 is the post title |
| Lovable | [#lovable](#lovable) | Nobody by default: the **SEO & AI search** review finds the gaps, **Try to fix** asks the agent to close them |
| Next.js | [#next-js](#next-js) | The Metadata API writes title and description; you write the JSON-LD script and the h1 |
| Replit | [#replit](#replit) | You or SEO Agent, in `client/index.html`; the SEO Rating scores three of the four |
| Webflow | [#webflow](#webflow) | Page settings write the title, description and JSON-LD on a paid plan; the h1 is the tag on a Designer element |
| Wix | [#wix](#wix) | SEO basics holds title and description, Advanced SEO holds JSON-LD, and the h1 is a Heading 1 text element |
| WordPress | [#wordpress](#wordpress) | The SEO plugin writes description and JSON-LD; the h1 is the Title block level in your theme template |

## What every platform has in common

C3 accepts one arrangement and no other: exactly one `<h1>`, a `<title>` with text in it, a meta description, and structured data in at least one JSON-LD block. The details differ by platform, but four rules hold everywhere, so each section below only says what is different:

1. **Read the delivered HTML.** What counts is what the server returns before any script runs. A heading or schema block that a browser builds afterwards is invisible to a fetch that does not run JavaScript.
2. **One h1.** Two usually means a layout and a page both rendered one, or an editor block added a second.
3. **JSON-LD must match the page.** [schema.org's Article type](https://schema.org/Article) and [Google's structured data introduction](https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data) both describe markup as a statement about the visible content. A copied block with placeholder values is a mismatch, not a pass.
4. **Presence is not correctness.** C3 records that a block exists. It does not grade the `@type`, whether the graph parses, or whether the description is unique. Validate the contents with [Google's Rich Results Test](https://search.google.com/test/rich-results).

## What our scans show

First-party data from the AIScan scan corpus, one latest scan per host on rubric `2026.08.2`, taken 9 September 2026.

| Group | Sites | Pass C3 | Fail C3 |
|---|---|---|---|
| All scanned sites | 501 | 248 | 253 |
| Detected as Next.js | 120 | 81 | 39 |
| Detected as WordPress (8 September 2026 scan) | 129 | 72 | 57 |

Of the 39 failing Next.js sites, 23 had a correct h1, title and description and no JSON-LD at all. The framework did its part; nobody added the one block it leaves to the developer. The WordPress row is the mirror image: 50 of its 57 failures were the heading count, with title, description and schema present. It comes from the 8 September scan of 498 sites, so its sample date differs from the rows above. Ghost, Lovable, Replit, Webflow and Wix sample sizes are too small to publish a split, so those sections describe the mechanism instead.

## Ghost

### Ghost owns three of the four signals automatically, and the fourth is the one nobody edits

Fetch a default Ghost theme's `default.hbs` and the mechanism is two lines. Casper, Ghost's own starter theme, puts this in the `<head>`, verified on the theme's own repository:

```html
<title>{{meta_title}}</title>
...
{{ghost_head}}
```

`{{meta_title}}` is a Handlebars helper, not a raw field lookup. According to Ghost's own theme documentation, it "generates automatic meta data by default, but it can be overridden with custom content in the post settings menu." Leave the Meta Title field in your post settings blank and the helper falls back to the post's title. Fill it in and the `<title>` tag diverges from the H1 on purpose. That's a feature, not a bug, and it's why your headline and your search-result title don't have to read identically.

`{{ghost_head}}` does the heavy lifting. According to that same documentation, one helper call outputs the meta description, "Structured data Schema.org microformats in JSON/LD," Open Graph and Twitter Card tags, RSS discovery links, and anything you've dropped into Code Injection, global or per-page. Miss `{{ghost_head}}` in a hand-rolled `default.hbs` and every one of those signals vanishes from the rendered page at once. That's the single most common way an otherwise up-to-date Ghost site fails a structured-HTML check: not a missing setting, a missing helper call.

### The h1 is your post title, and there is no separate override

Casper's `post.hbs` renders the heading with one line: `<h1 class="article-title">{{title}}</h1>`. That `{{title}}` is the raw post title, the same string that feeds your post URL slug and your browser tab. Ghost gives you a Meta Title field to change what search engines and the `<title>` tag show, and a Meta Description field to change the snippet, but no equivalent "H1 override" field. If you want different wording in your heading than in your search result, the only route is to write a Meta Title that differs from the actual title. The H1 will always match the title field, on every stock and custom theme built on Ghost's content API.

This is also where duplicate `<h1>` tags come from on Ghost specifically. Ghost's editor ships a Header card that a writer can drop into the body of a post, and a Header card set to its largest size renders its own `<h1>`. Put one at the top of a post on a theme that already renders `{{title}}` as an `<h1>`, and the page ships two. That's a structural duplicate with nothing to do with the theme being broken, and everything to do with an editorial habit.

### Title and meta description live in Post settings, not the editor toolbar

Both fields sit in the same place: open a post in the editor, click the settings icon, and scroll to **Meta data**. In its own words, Ghost's documentation for that panel advises keeping "the meta title and description within the recommended character limit" and writing them "for humans." Leave either blank and Ghost falls back to the post title and an automated excerpt, rather than leaving the tag empty. That fallback is worth knowing before you conclude a check has failed: an empty-looking field in the editor does not mean an empty tag on the page.

| Field | If you fill it in | If you leave it blank |
|---|---|---|
| Meta title | Becomes the `<title>` tag and the JSON-LD `headline` | Falls back to the post title |
| Meta description | Becomes the meta description tag and the JSON-LD `description` | Falls back to the custom excerpt, then an automatic 50-word excerpt |
| Canonical URL | Overrides the automatic canonical tag | Ghost sets its own canonical automatically |

The Canonical URL field in that same panel is worth using deliberately: it overrides Ghost's automatic canonical tag, which matters when you're republishing a piece that first ran elsewhere and don't want two indexed copies competing.

### JSON-LD is generated for you, and hand-authoring it is a trap

This is the signal with no dedicated field, and reading Ghost's own schema-building source explains why. Ghost core assembles a `schema.org/Article` object per post from fields you're already filling in for other reasons:

| Article field | Comes from |
|---|---|
| `headline` | Meta title (or the post title, if blank) |
| `keywords` | Your tags, joined |
| `description` | Meta description, then custom excerpt, then an automatic excerpt |
| `image` | The post's feature image |
| `datePublished` / `dateModified` | The post's own timestamps |
| `author` | The credited author's profile, bio and social links, as a nested `Person` object |

None of that is a form field labeled "schema." It's a side effect of the post you already wrote.

The trap is trying to fix a JSON-LD failure by hand-writing a `<script type="application/ld+json">` block through Code Injection. Ghost renders both: its own automatic block from `{{ghost_head}}`, and yours. Nothing stops two structured-data blocks from coexisting on one page, but a hand-written block that disagrees with the automatic one, a different headline or a different date, hands a crawler two conflicting answers to the same question instead of one. If a scan reports missing JSON-LD on a page that should have Ghost's automatic block, the fix is almost never Code Injection. It's confirming `{{ghost_head}}` actually made it into the theme's `default.hbs`.

### When you actually need Code Injection for schema

There is a legitimate use: schema types Ghost's own `Article` object doesn't cover, such as `FAQPage` or `HowTo` markup, or `Product` markup on a landing page built as a Ghost page rather than a post. According to Ghost's own help documentation, the feature lives in two places: **Settings → Advanced → Code Injection** for site-wide header and footer code, or, for one specific post, the **Code Injection** section inside that post's own settings panel, which accepts separate header and footer snippets scoped to that page alone. Add a second, different `@type` there and it sits next to the automatic `Article` block without conflict, because the two blocks describe different things. Add a second `Article` block and you're back in the trap above.

### Verify all four signals on the published page

```bash
curl -sL https://yoursite.com/your-post/ -o /tmp/p.html
python3 - <<'PY'
import re
h = open('/tmp/p.html', encoding='utf8', errors='ignore').read()
print("title:", re.search(r'<title>(.*?)</title>', h).group(1))
print("h1 count:", len(re.findall(r'<h1[ >]', h)))
print("meta description:", 'name="description"' in h)
ld = re.findall(r'<script type="application/ld\+json">(.*?)</script>', h, re.S)
types = [re.search(r'"@type":"(\w+)"', b).group(1) for b in ld if re.search(r'"@type":"(\w+)"', b)]
print("JSON-LD blocks:", len(ld), "| types:", types)
PY
```

A healthy Ghost post prints exactly one `<h1>`, a `<title>` that may or may not match it depending on whether you set a custom Meta Title, one meta description tag, and exactly one JSON-LD block typed `Article`, two only if you deliberately added a second, different type through Code Injection. Two `Article` blocks, or a JSON-LD count of zero on a theme that includes `{{ghost_head}}`, both point at a specific, findable cause rather than a mystery.


## Lovable

### Lovable doesn't give you a metadata file. It gives you an auditor.

Most other platforms has a place where title, meta description and schema live: a PHP template, a `metadata` export, a settings panel. Lovable has none of those for a project you didn't build the head tags into yourself. According to Lovable's own publishing documentation, fetched 23 September 2026, "Lovable generates your site's metadata while it builds your app: the site title, meta description, and site icon." There is no file to open and no field to type into for that part.

What Lovable gives you instead is a checklist that reads your actual code and your deployed pages, called the **SEO & AI search review**. Open it from **More → SEO & AI search** in the project toolbar, then click **Scan project**. Four of its findings map straight onto C3, listed here as verified on docs.lovable.dev:

| SEO & AI search review finding | What it covers | Maps to C3 |
|---|---|---|
| Page basics | `lang` attribute, viewport configuration | Supporting signal |
| Metadata | Title and meta description per page | Title + description |
| Structured data | JSON-LD present, parseable, matching the page | JSON-LD |
| Content structure | Missing or generic h1, skipped heading levels | h1 |

Each failing row gets a **Try to fix** button that hands the specific fix to the agent, or you can click **Try to fix all** and send every open finding in one message. The review runs on unpublished projects too, so nothing here waits for a live domain.

In our own corpus of 501 scanned sites, re-measured 9 September 2026, 248 currently pass all four C3 signals at once, worth knowing before you assume a partial score is unusual.

### Set the title and description by asking, not by editing

There is a genuine trap here for anyone used to a settings page: the title and description are not global. Lovable's documentation states plainly that "metadata is set per page… Lovable generates a unique title and description for every page of your app based on its content," and that changing it means asking in the project chat, for example: *"Change my site title to 'Acme Task Tracker' and write a meta description about team to-do lists."* That request is treated as a normal build message and spends credits like any other edit, according to the same page.

| To check the result | Open this | What you see |
|---|---|---|
| While editing | The page selector above the preview | That page's **Social** and **Search** cards |
| From the Publish dialog | The **⋮** menu, or the favicon next to the URL | **Social & search appearance** |
| Right after publishing | The **Your website is live** screen | An **Edit** link next to the site's title |

A metadata change only reaches the live site on your next publish, exactly like a code edit. If the project is already published, Lovable's reply ends with a **Publish to update the live site** button, so you don't have to hunt for the Publish dialog separately.

### Let the review add the missing h1, then check what it actually wrote

The **Content structure** check fails on a missing h1, a heading that skips from h1 to h3, or an h1 so generic it says nothing about the page, in Lovable's own words: "H1 is missing or too generic" and "Heading levels skip from H1 to H3." Clicking **Try to fix** sends that finding to the agent, which edits the component that renders the page rather than a template file, because on a Lovable project there usually isn't a separate template to edit.

Read the diff before you accept it. An agent asked to add an h1 on a page that already has a large, unstyled heading sometimes wraps a second element instead of promoting the first one, which trades a missing-h1 finding for a two-heading finding on the next scan. Confirming the tag count after every fix, not just after the whole pass, catches that in one page load instead of a full review cycle.

### JSON-LD is a finding, not a code snippet you paste in

The **Structured data** check is stricter than "does a script tag carrying JSON-LD exist." Lovable's own documentation for the check names three separate failure modes, verified on docs.lovable.dev: JSON-LD that "cannot be parsed," JSON-LD that "does not match the visible page," and a page that "clearly qualifies for a supported Google Search feature and already shows the facts that feature needs, but carries no markup for it." It's also explicit about what does not count as a problem: a missing generic `WebSite` or `Organization` block, and a missing `FAQPage` markup, are both called out as non-issues on their own.

**Try to fix** here either adds a new JSON-LD block matched to the page's actual content, a product page gets `Product`, an article gets `Article`, or repairs an existing block so its fields match what's on the page. If you'd rather brief the agent yourself, describe the entity and the fields you want rather than pasting a generic schema.org example. A copied `Organization` snippet with placeholder values is exactly the "does not match the visible page" failure the check is built to catch.

### Read the review panel and the rendered HTML. They can disagree.

A **Scanning your project…** header means results are stale; wait for it to finish before trusting a green check. After a **Try to fix** pass completes, the review marks that finding **Fixed** provisionally and only confirms it on the next scan, so run **Scan again** rather than taking the badge at face value.

Then check the deployed page itself, because Lovable's review and a crawler don't always see the same HTML:

```bash
URL=https://yoursite.com/some-page
curl -s "$URL" | grep -c '<h1' # expect 1
curl -s "$URL" | grep -o 'application/ld+json' | wc -l # expect 1 or more
curl -sI "$URL" | grep -i x-robots-tag  # a "noindex" here means you tested a preview host
```

If the curl output looks right but AIScan still reports a failure, the gap is almost always the template split covered in our full Lovable setup guide: an older React + Vite project only serves its pre-rendered HTML to crawlers Lovable has verified, and AIScan isn't on that list.


## Next.js

Next.js hands you a Metadata API that writes your `<title>` and your meta description into the head automatically. It does not write your JSON-LD, and it does not write your `<h1>`. Those two are ordinary markup you render in a component, and on real sites that split is exactly where check C3 breaks. Across 120 Next.js sites in our own scan history, 34 of the 39 that miss C3 miss it on JSON-LD alone. This guide fixes all four signals in the App Router, in the order they actually fail.

### Three mechanisms, four signals

C3 wants four things on the page and accepts only one arrangement of them: a heading count of exactly one, a `<title>` with text in it, a meta description, and structured data in at least one JSON-LD block. Next.js covers two of the four from a single export and leaves the rest to you.

The Metadata API is the covered half. According to the `generateMetadata` reference, metadata objects from every segment in a route are merged shallowly from the root layout down to the page, and duplicate keys are replaced by the last segment that defines them. So a `title.template` in `app/layout.tsx` decorates every child title, and a `description` set in the root layout survives on any page that never sets its own.

JSON-LD is the uncovered half, and the docs are direct about it. The Next.js JSON-LD guide says, verbatim: "Our current recommendation for JSON-LD is to render structured data as a `<script>` tag in your `layout.js` or `page.js` components." There is no `jsonLd` metadata field. The framework's own site follows that advice: a plain fetch of the Next.js homepage on 9 September 2026 returned two `application/ld+json` blocks and both sit in the body, not the head.

The `<h1>` has no framework mechanism at all. Nothing in the Metadata API touches body markup, so a single visible heading is yours to get right.

### What 120 Next.js sites actually get wrong

We grouped the C3 evidence strings across our own corpus on 9 September 2026, deduped to one latest scan per host on rubric `2026.08.2`. C3 passes on 248 of 501 sites. Of the 120 detected as Next.js, 81 pass and 39 do not.

| Failing pattern on Next.js | Sites |
|---|---|
| Everything correct except JSON-LD | **23** |
| No JSON-LD, plus a heading-count problem | 9 |
| Heading count wrong, JSON-LD present | 4 |
| Missing title or meta description | 3 |

Read the first row twice. Twenty-three of the 39 failures have one `<h1>`, a title, a description and no structured data: the framework doing its job, and nobody reaching the one step it left out. Our WordPress guide found the mirror image, where 50 of 57 failures were the heading count.

### Add the JSON-LD block Next.js will not add for you

Render it inside the page component, next to your content. According to the same guide, `JSON.stringify` does not sanitise strings used in injection attacks, so replace `<` with its unicode escape:

```tsx
// app/blog/[slug]/page.tsx
export default async function Page({ params }) {
  const { slug } = await params
  const post = await getPost(slug)

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    description: post.excerpt,
    datePublished: post.date,
  }

  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
        }}
      />
      <h1>{post.title}</h1>
      {/* ... */}
    </article>
  )
}
```

Use a native `<script>` rather than `next/script`. In its own words, the guide notes that `next/script` is built for loading executable JavaScript, and structured data is not code.

### Set title and description where the framework can see them

Both come from one export, and both have a rule that fails quietly.

```tsx
// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: { template: '%s | Acme', default: 'Acme' },
  description: 'One sentence describing the site, 150 to 160 characters.',
}
```

The quiet rule, according to the reference: the `metadata` object and `generateMetadata` exports are only supported in Server Components. Put `export const metadata` in a file carrying `'use client'` and nothing is emitted, no warning appears, and C3 reports `title: false`. The reason given is that metadata has to resolve on the server before the page renders so it can go into the initial HTML response. Keep `page.tsx` a Server Component and move interactive parts into their own client file.

### The streaming trap that hides your title from AI crawlers

When `generateMetadata` waits on runtime data, Next.js streams the page first. The reference says the resolved tags are then "appended to the `<body>` tag", and adds that this was verified with bots that execute JavaScript and inspect the full DOM. Crawlers that cannot run JavaScript get a blocking render instead, and Next.js decides which is which from the user agent.

That list is a single regular expression, `HTML_LIMITED_BOT_UA_RE`, in the framework's source. We fetched it on 9 September 2026 and matched it against the 33 crawler tokens in our own AI crawler inventory.

| Matched by the blocking list | Not matched |
|---|---|
| Applebot, Applebot-Extended | GPTBot, ChatGPT-User, OAI-SearchBot |
| `facebookexternalhit` | ClaudeBot, Claude-User, Claude-SearchBot |
| Five Google tokens, including Google-Extended | PerplexityBot, Perplexity-User, CCBot |
| **8 of 33** | Amazonbot, `meta-externalagent`, and 16 more: **25 of 33** |

None of the eleven crawlers last measured as non-rendering appears on it. Googlebot is absent too, and correctly so, because it renders JavaScript. The others do not have that excuse. If your metadata defers to request time, add the crawlers you care about:

```ts
// next.config.ts
const config = {
  htmlLimitedBots: /GPTBot|ClaudeBot|PerplexityBot|CCBot|facebookexternalhit/i,
}
export default config
```

Or make the metadata static, which is faster and needs no list. The docs note that overriding this option can lengthen response times.

### Write the h1 yourself, once per page

Thirteen of the 39 failing Next.js sites report a heading count other than one, ten of them zero. Both causes are layout-shaped: a root layout rendering a site name as `<h1>` while the page renders another, or a visible title that is styled text in a `<div>`. Put exactly one `<h1>` in the page component, keep the site name in the layout as a `<p>` or a `<span>`, and let the title tag carry the branding through `title.template`.

### Scan it, then read the four booleans

Run the scan first. It reads all four signals in one pass and prints the evidence string C3 decided on:

```bash
npx aiscan-cli yoursite.com
```

Look at the **C3** row. It returns `h1: N, title: bool, meta description: bool, JSON-LD: bool`, and only `h1: 1` with all three true is a pass. You can paste the URL at [AIScan](https://aiscan.site/) instead if you would rather not install anything.

To check by hand, read the initial HTML the way a non-rendering crawler does:

```bash
URL=https://yoursite.com
curl -sL --compressed "$URL" -o /tmp/p.html
grep -c '<h1' /tmp/p.html
grep -o '<title>[^<]*</title>' /tmp/p.html
grep -c 'name="description"' /tmp/p.html
grep -c 'application/ld+json' /tmp/p.html
```

You want 1, one line of real text, 1, and at least 1. Validate the block itself with Google's Rich Results Test or the Schema Markup Validator, both named in the Next.js guide.


## Replit

### Replit doesn't have a settings panel. It has an audit that runs after you publish.

Most other platforms keeps title, description and schema somewhere you can open before going live: a template file, a `metadata` export, a settings tab. Replit's equivalent, the **SEO Rating**, only exists once a deployment is public. According to Replit's own documentation on the feature, verified on 24 September 2026, publishing an Autoscale, Reserved VM or Static app produces a Lighthouse audit of your live URLs within a minute or two, shown as a badge in the Publishing tool: Healthy, Needs Work or Weak.

If the rating has room to grow, **SEO Agent** turns the findings into one-click fixes. It lives in the **Growth** pane and groups issues into four categories:

| SEO Agent category | What it covers |
|---|---|
| AI Readiness | Signals aimed at crawlers that aren't traditional search engines |
| Crawlability & Discovery | `robots.txt`, `sitemap.xml`, link structure |
| Landing Page Rendering & Metadata | Title tags, meta descriptions, heading order, alt text |
| Performance Proxies | Load-time signals that feed into the same score |

Two things gate the tool itself, both stated on the same page: the app must already be published, and the builder must be on a **paid plan**. A free-plan project shows an upgrade prompt where the fix buttons would sit. Replit's documentation, fetched from docs.replit.com the same day, lists structured data among what SEO Agent will fix: "Structured data: JSON-LD markup that helps search engines show rich results." Title tags, meta descriptions and heading order are on that same list. Read only that list and C3 looks fully covered by one "optimize my SEO" request to Agent.

### The gap: Lighthouse scores three of these signals, and skips the fourth

| Lighthouse SEO audit | Automatically scored? |
|---|---|
| Document has a `<title>` element | Yes |
| Document has a meta description | Yes |
| Heading elements appear in a sequentially descending order | Yes |
| Structured data is valid | **No, manual check only** |

Google's own Lighthouse documentation for the last row, fetched from developer.chrome.com on 24 September 2026, places "Structured data is valid" under a section it calls **Manual checks**, the audits Lighthouse cannot score on its own and instead leaves for a human to confirm with Google's Rich Results Test. That means an app can carry a Healthy SEO Rating with zero JSON-LD anywhere on the page, because the one signal that would catch it was never part of the score to begin with. AIScan's C3 check reads the delivered HTML directly and reports a genuine pass or fail on structured data, which is the reading Replit's own badge cannot give you.

### Fix the title and meta description first, this part Replit already catches

On an Agent-built app, the front end lives under `client/`, with Vite's `root` pointed at that folder and `build.outDir` set to `dist/public`, the same scaffold covered in [the llms.txt guide for Replit](https://aiscan.site/blog/llms-txt-setup-every-platform#replit). The `<title>` and `<meta name="description">` tags sit in the `<head>` of **client/index.html**, and because Vite ships that file once at build time, both tags are present in the raw response even though the rest of the page is client-rendered.

Two ways to set them:

1. **Ask Agent.** Open the **Growth** pane, find the **SEO Rating** card, click **Run scan with Agent**, then expand **Landing Page Rendering & Metadata**. **Fix with Agent** on the title or description row hands that one finding to Agent; **Fix all with Agent** sends every open issue at once. Review the change, click **Apply changes to main version**, then republish.
2. **Edit the file directly.** Open **client/index.html** in the file tree and write the two tags by hand. A generic scaffold title such as "Vite + React + TS" is the most common finding here.

Most Agent-built apps serve one page to every route, so getting these two tags right in that single file covers the whole app. An app with genuinely separate routed pages needs per-route title and description handling, typically a library such as `react-helmet-async` in a client-rendered React app, which is a larger change than SEO Agent's one-click fix.

### JSON-LD lives in the same file, but you have to check it yourself

Add structured data to the same `<head>`, either by asking Agent for it (it is on SEO Agent's own documented fix list) or by pasting a script tag directly:

```html
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "WebApplication",
  "name": "Your App Name",
  "url": "https://yourapp.replit.app",
  "applicationCategory": "BusinessApplication"
}
</script>
```

Because the SEO Rating never scores this, a Healthy badge is not evidence it worked. Confirm it two ways instead: fetch the raw page and look for the script tag yourself, then run the URL through Google's Rich Results Test, the same manual step Lighthouse's own documentation points to in place of an automatic score.

### The h1 problem is Autoscale's rendering order, not a missing element

This is the signal most likely to fail on an Agent-built app, and Replit's own rating is the least likely tool to catch it. The production server, per the scaffold's `server/vite.ts`, serves the same **client/index.html** shell for every path and lets your React code fill in the actual content, including any `<h1>`, after the browser runs the JavaScript bundle. Lighthouse executes that JavaScript in a real browser before it audits the page, so the SEO Rating sees the finished heading and can report success. A fetch that never runs JavaScript, `curl`, most AI agents, AIScan's own crawler, sees the static shell and nothing else: zero headings in it.

Static Deployments don't have this problem. According to Replit's deployment documentation, a Static Deployment serves pre-rendered files directly rather than running a server, so whatever heading is baked into the build is what every reader gets, JavaScript or not. On Autoscale and Reserved VM apps built by Agent, the fix is to put a real `<h1>` matching the text your app actually renders as its main heading directly inside **client/index.html**, so it exists before any script runs rather than only after. Preview any Agent-applied fix before accepting it: a heading added next to an existing large unstyled one can produce two h1s instead of one.

### Confirm all four signals on the domain that actually ships

Fetch the deployed app the way an agent would, without a browser:

```bash
curl -s https://yourapp.replit.app/ | grep -oE '<h1[^>]*>[^<]*</h1>'
curl -s https://yourapp.replit.app/ | grep -oE '<title>[^<]*</title>'
curl -s https://yourapp.replit.app/ | grep -c 'application/ld+json'
```

Expect exactly one `<h1>` carrying real page text, one non-generic `<title>`, and at least one `application/ld+json` match. Zero on any of those three is the finding to fix, regardless of what the Growth pane currently shows.

## Webflow

### Webflow writes three of the four signals in its SEO panel and leaves the h1 on the canvas

On a paid Site plan or a paid Workspace, a page's title tag, meta description and JSON-LD all have fields in Page settings. The heading level has no field: it is the tag on a Designer element, and that is where most Webflow pages lose C3. Webflow's accessibility article *Use descriptive, sequential headings* tells authors to *"use one H1 per page that describes its purpose (or only use multiple H1s when a page truly has more than one purpose)"*. That parenthesis is sensible for a screen reader and a fail for a check that counts.

| C3 signal | Where it lives in Webflow | Who can set it |
|---|---|---|
| `<title>` | **Pages panel > Page settings > SEO settings > Title tag** | Any role except Reviewer, on a paid plan |
| Meta description | Same panel, **Meta description** | Same |
| JSON-LD | **Page settings > Schema markup** | Same |
| Exactly one `<h1>` | Nowhere: the tag on a Designer element | Anyone who can edit the canvas |

Webflow's own sites show the split. Fetched on 10 September 2026 with a browser user agent, `webflow.com` returned one title, one meta description, one JSON-LD block and two `<h1>`; `university.webflow.com` returned the same three and one `<h1>`. Two hosts is not a survey, but it is the shape of the problem: the panel-owned signals were right on both and the heading count was wrong on one.

### Title tag and meta description, and the template pattern that fills every CMS item

Open the **Pages panel**, hover the page, click the **Settings** icon and scroll to **SEO settings**. Type a title tag under 60 characters and a meta description of 150 to 160 characters, then **Save** and **Publish**. Nothing in either field reaches a crawler before a publish.

There is no site-wide fallback. Webflow states that *"You can't set SEO settings globally, and SEO settings apply only to the page on which you've set them."* A WordPress plugin templates a description for posts you never touched; Webflow leaves the field empty, and an empty meta description fails C3. On a Collection page, set the pattern once: in **Page settings > SEO settings**, use **Add field** inside the Title tag and Meta description boxes and bind them to the item name and a summary field. Every item, including next year's, inherits it.

### JSON-LD belongs in Schema markup, not in custom code

Go to **Pages panel > hover the page > Settings icon > Schema markup**, paste your JSON-LD or click **Generate schema markup**, then **Save** and publish. Check it with the Schema Markup Validator, which Webflow's article *Add schema markup in Webflow to improve SEO and AEO* names. Per Webflow's FAQ, markup in this field lands in the `<head>`, while markup pasted into the **Before `</body>` tag** box lands at the end of the document. Both parse, but the head is where validators, scanners and most agents look first. Existing custom-code schema keeps working, so nothing forces a migration, but keep each page to one route to avoid duplicate structured data.

On a Collection page the field takes dynamic bindings, with one limit: Plain text, Number, Date/Time, Option, Switch, Image, Video link, Link, File, Color, Email and Phone fields work; Reference, Multi-reference and Multi-image fields do not. If a Product schema wants a brand stored as a Reference, store a plain-text copy instead.

### The three ways an h1 goes wrong on the canvas

Select the heading in the Designer, open the element settings and set the tag to **H1**.

| What the scan reports | What is on the canvas | The fix |
|---|---|---|
| `h1: 0` | The headline is a Text Block or Div with large type: it looks like a headline and parses as a paragraph | Set the element tag to **H1**, restyle if needed |
| `h1: 2` or more | A hero heading plus a second heading inside a Symbol or component reused in the navigation or footer | Retag the one inside the component; every page using it is fixed at once |
| One h1, same text everywhere | On a Collection page the H1 is static template text | Bind the H1 to the item's own name field |

Verify on the published domain, never the Designer preview, because unpublished settings do not exist yet:

```bash
curl -s https://yoursite.com/your-page \
  | grep -o -E '<h1|<title>|name="description"|application/ld\+json' | sort | uniq -c
```

A pass prints one `<h1`, one `<title>`, one `name="description"` and at least one `application/ld+json`. Our platform detector has a matching blind spot: on the scan corpus it classifies zero sites as Webflow, although every Webflow page ships `<meta content="Webflow" name="generator"/>`. That is logged as a bug on our side and does not change your C3 result. For the rest of the Webflow rubric, see [the Webflow setup guide](https://aiscan.site/blog/ai-readiness-setup-guide#webflow) and [the llms.txt route](https://aiscan.site/blog/llms-txt-setup-every-platform#webflow).

## Wix

### Wix splits the four signals across three surfaces

Title tag and meta description sit in a page's **SEO basics** panel. JSON-LD is one tab over, in **Advanced SEO**, or at dashboard level in SEO Settings. The h1 is in no panel: it is a **Heading 1** text element on the canvas, which is why it is the signal that goes missing. Nothing in the editor stops you deleting the one Heading 1 a template shipped with, or styling a plain text element to look like it.

| Signal | Where it lives on Wix | Check by hand |
|---|---|---|
| Title tag | Page's **SEO basics** panel | `curl -s <url> \| grep -o '<title>[^<]*'` |
| Meta description | Same panel, next field down | `curl -s <url> \| grep -o 'name="description"[^>]*'` |
| JSON-LD | **Advanced SEO** tab, or the SEO Settings dashboard | `curl -s <url> \| grep -c 'application/ld+json'` |
| H1 | A **Heading 1** text element | `curl -s <url> \| grep -o '<h1[^>]*>'` |

### Title tag and meta description in SEO basics

In the editor click **Pages & Menu**, **More Actions** beside the page, then **SEO basics**. Enter the headline under **Title tag (title in search results)** and the summary under **Meta description (description in search results)**, then **Publish**. Per Wix's help article on title tags and meta descriptions (verified 25 September 2026), a title should stay under 60 characters and a description should run 50 to 160. Wix recommends `Keyword | Location | Brand Name` for local businesses and `Keyword | Brand Name` otherwise, and says AI bots rely on an accurate meta description when summarizing a page. C3 checks presence only, so the format is good practice and not part of the grade.

### JSON-LD: Advanced SEO per page, SEO Settings per page type

From **SEO basics** click **Advanced SEO**, then **Structured Data Markup**, then **+Add New Markup**, paste the JSON-LD and click **Apply**. Wix's structured data article (verified 25 September 2026) says it accepts JSON-LD only, no Microdata or RDFa, caps each markup under 7,000 characters and allows five per page. Type names are case-sensitive: `blogposting` instead of `BlogPosting` will not validate.

| Route | Where | Scope | Best for |
|---|---|---|---|
| Advanced SEO tab | Per page, in the editor | That page only | An About page or a landing page |
| SEO Settings dashboard | Site dashboard > SEO & GEO > Tools and settings | Every page of one page type, with variables | Blog posts, Stores products, Bookings services |

Wix adds Local Business markup to the homepage once you enter a business name and location, and presets markup on Stores, Bookings, Blog and Events pages. That homepage block does not cover other pages, because C3 is scored per page. An AI-generated markup toggle in SEO Settings covers published Wix Blog posts only; every other page type needs the manual route.

### The h1 is a text element, and a blank line breaks it

To add one, click **Add Elements > Text > Add Heading 1** and drag it onto the page. To retag existing text without changing its look, select it, click **Edit Text > SEO & accessibility** and pick a tag under **Choose HTML tag**. Wix's heading-tags article (fetched 25 September 2026) warns that assigning Heading 1 to a text box containing a blank line break generates an empty `<h1>`. A check that counts tags sees it as present; a reader sees nothing where the headline should be. And because Heading 1 is a style choice, a second one dropped in by hand renders as a second `<h1>`.

Scan the live domain, not a `wixsite.com` preview:

```bash
npx aiscan-cli yoursite.com
curl -s https://yoursite.com | grep -Eo '<title>[^<]*|name="description"[^>]*|application/ld\+json|<h1[^>]*>'
```

Expect a title, one description tag, at least one JSON-LD script and exactly one `<h1`. The served HTML updates right after Publish; only Google's cached snippet lags. The file half of the job is in [the Wix llms.txt route](https://aiscan.site/blog/llms-txt-setup-every-platform#wix) and [the Wix robots.txt guide](/blog/robots-txt-starter-for-ai-crawlers#wix).

## WordPress

### On WordPress the h1 belongs to the theme, and that is where C3 fails

In our scan corpus on 8 September 2026, 72 of 129 WordPress sites passed C3. Of the 57 that did not, 50 missed on one signal alone: the heading count. Their title, meta description and schema were present. The page had no `<h1>` or several, and no SEO plugin fixes that, because the heading is a block attribute stored in the theme's template.

| C3 signal | WordPress sites missing it (of 129) |
|---|---|
| Exactly one `<h1>` | 50 (39 with none, 11 with more than one) |
| Meta description | 24 |
| JSON-LD | 18 |
| `<title>` | 2 |

The plugin-shaped half of C3 is mostly solved already. The theme-shaped half is not. Ten sites reported exactly `h1: 2`.

### Why the heading goes missing

In a block theme the post title is rendered by a block, not typed into the page. According to the core blocks reference on developer.wordpress.org (fetched 8 September 2026), `core/post-title` carries a `level` attribute, saved in template markup and shipped by whoever built the theme. If they set it to 2, your posts have no h1 and nothing in the post editor says so. WordPress's documentation notes *"Post Title block was renamed to Title block."* The markup still says `core/post-title`; the label in the sidebar reads **Title**. The opposite failure has the same cause: `core/site-title` also has `level`, so a header that renders the site name as an h1 on top of a post title h1 gives two.

### Set the Title block to H1 in the Site Editor

1. Go to **Appearance > Editor**.
2. Click **Templates** and open the template your single posts use.
3. Select the **Title** block and set its level to **H1**.
4. Click **Save**.

```html
<!-- wp:post-title {"level":1,"isLink":true} /-->
```

For the second h1, open **Appearance > Editor > Patterns**, edit the header template part and give the **Site Title** block a level other than H1. A site name repeated on every page is not the subject of any page. Changing the level can change how the title looks, because themes style by level; set the level first, then adjust size in the block's typography controls.

### Classic themes: the title tag is opt-in

Core prints the title tag from one function, quoted from developer.wordpress.org:

```php
function _wp_render_title_tag() {
	if ( ! current_theme_supports( 'title-tag' ) ) {
		return;
	}
	echo '<title>' . wp_get_document_title() . '</title>' . "\n";
}
```

No `add_theme_support( 'title-tag' )`, no title tag. Add it inside an `after_setup_theme` callback in a child theme's `functions.php`. To change the text without a plugin, filter `document_title_parts` or `document_title_separator`. The `Appearance > Editor` menu exists only on block themes; on a classic theme the h1 is a PHP template call to `the_title()` inside a heading tag.

### Meta description and JSON-LD come from one plugin, not three

Run exactly one SEO plugin. Two on one site is the reliable way to ship two meta descriptions and two competing graphs. [ThinkRank](https://thinkrank.ai) is the one we recommend: it handles robots.txt, robots meta, schema, sitemaps and llms.txt from a single plugin and imports settings from Rank Math, Yoast, All in One SEO and SEOPress. Reading a live ThinkRank install's schema settings on 8 September 2026 showed `auto_generate_schema` and `auto_deploy` on, with `Organization`, `Person` and `WebSite` enabled. Yoast, Rank Math, All in One SEO and SEOPress also write the description and JSON-LD. None of the five sets your h1.

### If the scan still reports h1: 0

Four causes cover almost all of it. You edited a template your posts do not render through. A caching plugin or CDN is serving yesterday's HTML, so purge and re-scan. The level is set inside a pattern that keeps re-applying it. Or the block sits in a query loop that renders archives, not single posts. A page builder bypasses the Site Editor entirely, so set the tag on the builder element that outputs the title. After a theme switch or update, re-scan: template markup ships with the theme, and a level you fixed can return to 2. Scan a real post as well as the homepage, because C3 is scored per page. Plain `curl` is enough on WordPress because it renders on the server, which is not true of every stack; [React and Next.js sites need a different test](https://aiscan.site/blog/nextjs-react-invisible-to-ai-crawlers). More WordPress steps are in [the setup guide](https://aiscan.site/blog/ai-readiness-setup-guide#wordpress), and what Google does with snippets once the markup is right is in [the AI Overviews guide](https://aiscan.site/blog/google-ai-overviews-what-you-can-control). The check definitions are at [/docs/platforms/wordpress](https://aiscan.site/docs/platforms/wordpress).

## Verify all four signals on any platform

Whatever the platform, finish with the same check. Run the scan and read the **C3** row:

```bash
npx aiscan-cli yoursite.com
```

Then confirm by hand against the published domain, never the preview address:

```bash
URL=https://yoursite.com/some-page
curl -sL --compressed "$URL" -o /tmp/p.html
grep -c '<h1' /tmp/p.html
grep -o '<title>[^<]*</title>' /tmp/p.html
grep -c 'name="description"' /tmp/p.html
grep -c 'application/ld+json' /tmp/p.html
```

You want 1, one line of real text, 1, and at least 1. If AIScan still disagrees with the platform's own panel, the platform is usually reading source or a rendered DOM while AIScan reads the bytes a plain fetch receives; the section for your platform says which.

## Where AIScan fits, and where it doesn't

[AIScan](https://aiscan.site/) reads the HTML your server returns and reports the four C3 signals as a pass or fail, free and with no account. It does not rewrite your files, judge whether your JSON-LD type is the best choice, or validate the graph. Pair it with a schema validator when the markup itself, not just its presence, is in question. If an app of this kind is the front end for headless WordPress, the schema, robots rules, sitemaps and llms.txt still come from the WordPress origin, which is what [ThinkRank](https://thinkrank.ai) manages.

Check definitions for this dimension are at [/docs/checks/content](https://aiscan.site/docs/checks/content). The same signals for a CMS are in [the full AI readiness setup guide](https://aiscan.site/blog/ai-readiness-setup-guide) and [the llms.txt guide for every platform](https://aiscan.site/blog/llms-txt-setup-every-platform). Every platform guide lives at [aiscan.site/guides](https://aiscan.site/guides).

*Updated on 29 October 2026. Sections re-checked against the platforms' own documentation on the dates they state.*

