Crawling your published pages puts every title and description in one report, so stale static pages and template-wide CMS mistakes surface before searchers see them.
Metadata can go wrong in two ways. A static page can go stale when the offer changes. A Collection template can go wrong once and then everywhere: one hardcoded description repeats on every item it renders, and every item still looks fine in the Designer.
In this guide, we’ll build a small Next.js app on Webflow Cloud with one Route Handler. It fetches the URLs you give it, reads the title, description, canonical and Open Graph tags from the published HTML and returns the problems as JSON: missing, too long, duplicated or mismatched.
What do you need to audit metadata in Webflow?
You need a Webflow site with Webflow Cloud, a Next.js 15+ project managed with npm, a Git repository, a list of URLs, an audit secret, and Designer access:
- Webflow site: Any plan from free Starter up includes Webflow Cloud; a custom-domain mount needs Premium or higher.
- Next.js 15 or higher: This is the floor on Webflow Cloud's bring-your-own-app page; manage dependencies with npm only.
- Git repository: Use a repository for the project, then connect that repository when you create the Webflow Cloud environment.
- URL list: Include every static page plus the CMS item URLs that the published-page audit needs to crawl.
- Audit secret: Use a long random string held by whoever runs the audit and kept out of the repository.
- Designer access: This is needed to fix what the report flags on static pages and on collection template pages
You now have the site, project, access, URL inventory, and secret needed to build and run the audit
One command turns a sitemap into the comma-separated list the audit reads:
curl -s https://example.com/sitemap.xml | grep -o '<loc>[^<]*' | sed 's/<loc>//' | paste -sd, -
Send the list in the urls array of the JSON body, at most 100 per request. AUDIT_URLS suits only a short fixed list: Cloudflare caps each environment variable at 5 KB, which a sitemap of more than about 80 URLs will exceed.
With those in hand, the build depends on one decision: parse the published HTML instead of querying the CMS, so a single code path covers static pages and collection items the same way.
6 steps to audit and optimize meta data in Webflow Cloud
I build the audit from a small parser module and a secret-protected Route Handler; fixes happen in the Designer and the CMS. The handler runs on Cloudflare Workers, so the Next.js edge directive stays out of the route file.
The first four steps produce a working report locally; the last two put the fixes in and move the audit onto the live site.
1. Scaffold the Next.js app with npm
Create the project with npm from the start. Webflow Cloud's docs say, "Currently, Webflow Cloud supports only the npm package manager," so a pnpm or yarn scaffold produces a lockfile the platform will not use.
I pass --use-npm explicitly rather than relying on whatever package manager the machine defaults to, because a teammate's global config is the usual way a stray pnpm-lock.yaml ends up in the repo.
Two lines get you a project with the App Router and TypeScript:
npx create-next-app@15 meta-audit --typescript --app --use-npm
cd meta-audit
Accept the defaults for the remaining prompts, including the @/* import alias, which the later files rely on. Leave next.config without an adapter or output mode, and skip basePath too; Webflow Cloud's configuration page states, "You don't need to add an adapter, a base path, or an output mode," and the mount path is injected at build time.
Run npm run dev once and open http://localhost:3000; you should see the default Next.js page, which confirms the toolchain works before any audit code exists.
2. Write the meta parser and audit rules
Put the parsing and the rules in one library file, separate from the Route Handler, so the same functions can later run from a script or a scheduled job without going through HTTP.
I parse with regular expressions rather than a DOM library because the handler runs on Workers, not in a browser, and the handful of tags an audit needs (title, description, canonical, Open Graph) are regular enough that a <meta> attribute scanner covers them.
The LIMITS constants hold my defaults of 60 characters for a title and 160 for a description; change them if your team holds a different line.
Code implementation
Create lib/meta-audit.ts with this content:
export type MetaRecord = {
url: string;
status: number;
title: string | null;
description: string | null;
canonical: string | null;
ogTitle: string | null;
ogDescription: string | null;
};
export type Finding = { url: string; rule: string; detail: string };
export const LIMITS = { titleMax: 60, descriptionMax: 160 };
function decode(value: string): string {
return value
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/&/g, '&') // last, so "&lt;" is not decoded twice
.trim();
}
function attributes(tag: string): Record<string, string> {
const out: Record<string, string> = {};
for (const m of tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
out[m[1].toLowerCase()] = m[2] ?? m[3] ?? '';
}
return out;
}
function metaContent(html: string, key: 'name' | 'property', value: string): string | null {
for (const m of html.matchAll(/<meta\b([^>]*)>/gi)) {
const attrs = attributes(m[1]);
if (attrs[key]?.toLowerCase() === value && attrs.content) return decode(attrs.content);
}
return null;
}
function linkHref(html: string, rel: string): string | null {
for (const m of html.matchAll(/<link\b([^>]*)>/gi)) {
const attrs = attributes(m[1]);
if (attrs.rel?.toLowerCase() === rel && attrs.href) return attrs.href;
}
return null;
}
export function parseMeta(url: string, status: number, html: string): MetaRecord {
const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1];
return {
url,
status,
title: title ? decode(title) : null,
description: metaContent(html, 'name', 'description'),
canonical: linkHref(html, 'canonical'),
ogTitle: metaContent(html, 'property', 'og:title'),
ogDescription: metaContent(html, 'property', 'og:description'),
};
}
async function fetchOne(url: string): Promise<MetaRecord> {
try {
const res = await fetch(url, {
headers: { 'user-agent': 'meta-audit/1.0' },
redirect: 'manual',
signal: AbortSignal.timeout(8000), // one slow page cannot eat the 20s request budget
});
const html = res.status === 200 ? await res.text() : '';
return parseMeta(url, res.status, html);
} catch {
return parseMeta(url, 0, '');
}
}
export async function crawl(urls: string[], batchSize = 5): Promise<MetaRecord[]> {
const records: MetaRecord[] = [];
for (let i = 0; i < urls.length; i += batchSize) {
const batch = urls.slice(i, i + batchSize);
records.push(...(await Promise.all(batch.map(fetchOne))));
}
return records;
}
const strip = (u: string) => u.replace(/\/$/, '');
export function audit(records: MetaRecord[]): Finding[] {
const findings: Finding[] = [];
const titles = new Map<string, string>();
const descriptions = new Map<string, string>();
const flag = (url: string, rule: string, detail: string) => {
findings.push({ url, rule, detail });
};
for (const r of records) {
if (r.status === 0) { flag(r.url, 'unreachable', 'Request failed'); continue; }
if (r.status >= 300 && r.status < 400) { flag(r.url, 'redirect', `HTTP ${r.status}`); continue; }
if (r.status !== 200) { flag(r.url, 'unreachable', `HTTP ${r.status}`); continue; }
if (!r.title) {
flag(r.url, 'title-missing', 'No <title> element');
} else {
if (r.title.length > LIMITS.titleMax) flag(r.url, 'title-long', `${r.title.length} characters`);
const prior = titles.get(r.title);
if (prior) flag(r.url, 'title-duplicate', `Same title as ${prior}`);
else titles.set(r.title, r.url);
}
if (!r.description) {
flag(r.url, 'description-missing', 'No meta description');
} else {
if (r.description.length > LIMITS.descriptionMax) {
flag(r.url, 'description-long', `${r.description.length} characters`);
}
const prior = descriptions.get(r.description);
if (prior) flag(r.url, 'description-duplicate', `Same description as ${prior}`);
else descriptions.set(r.description, r.url);
}
if (r.canonical && strip(r.canonical) !== strip(r.url)) flag(r.url, 'canonical-mismatch', r.canonical);
if (r.title && r.ogTitle && r.ogTitle !== r.title) flag(r.url, 'og-title-differs', r.ogTitle);
if (r.description && r.ogDescription && r.ogDescription !== r.description) {
flag(r.url, 'og-description-differs', r.ogDescription);
}
}
return findings;
}
Two choices here are deliberate. redirect: 'manual' makes a 301 show up as its own finding instead of silently auditing whatever page the redirect lands on, which is how a retired URL that still sits in a nav hides from a crawl.
And the duplicate maps key on the exact string, so a template page that emits the same description for every item produces one description-duplicate finding per item after the first, which makes the scale of a template problem obvious in the count.
You now have a library that turns raw HTML into records and records into findings, with nothing in it that depends on Node modules the Workers runtime lacks.
3. Expose the audit as a protected Route Handler
I protect the library with a POST handler that requires the right secret header, keeping control of outbound fetches with you. An unauthenticated version is an open relay someone can point at any site; for an agency build, checking before parsing the body keeps that liability under the client's control.
The handler accepts a URL list in the request body and falls back to a comma-separated AUDIT_URLS environment variable, so a scheduled caller can run it with an empty body.
The route is protected by a shared-secret header read only on the server, compared character by character rather than with a plain early-exit ===. It has no rate limit and fetches whatever URLs it is given, so anyone with the secret can point it at any site; keep the secret private and rotate it if it leaks.
NEXT_PUBLIC_ secret leakage is absent: AUDIT_SECRET is not prefixed with NEXT_PUBLIC_ and therefore is not intentionally exposed to the browser; the later NEXT_PUBLIC_BASE_PATH value is public routing data, not a secret.
Important considerations
Two things are intentionally missing from this file. There is no export const runtime = 'edge', even though the bring-your-own-app page recommends it, because the OpenNext Cloudflare adapter does not support the Next.js edge runtime target.
And the secret comparison is written by hand rather than with crypto.subtle.timingSafeEqual, because that Cloudflare extension exists on the deployed Workers runtime but not under local next dev, and I want the handler to behave identically in both.
Route Handler location
The handler lives at app/api/meta-audit/route.ts:
import { NextResponse } from 'next/server';
import { audit, crawl } from '@/lib/meta-audit';
const MAX_URLS = 100;
function authorized(request: Request): boolean {
const expected = process.env.AUDIT_SECRET ?? '';
const provided = request.headers.get('x-audit-secret') ?? '';
if (!expected || expected.length !== provided.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) {
diff |= expected.charCodeAt(i) ^ provided.charCodeAt(i);
}
return diff === 0;
}
export async function POST(request: Request) {
if (!authorized(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = (await request.json().catch(() => ({}))) as { urls?: unknown };
const fromBody = body.urls;
const urls =
Array.isArray(fromBody) && fromBody.every((u) => typeof u === 'string')
? (fromBody as string[])
: (process.env.AUDIT_URLS ?? '').split(',').map((u) => u.trim()).filter(Boolean);
if (urls.length === 0) {
return NextResponse.json({ error: 'No URLs supplied' }, { status: 400 });
}
// One request has 20 seconds; batches of 5 keep well inside that at this cap.
if (urls.length > MAX_URLS) {
return NextResponse.json(
{ error: `Send at most ${MAX_URLS} URLs per request`, received: urls.length },
{ status: 413 }
);
}
const records = await crawl(urls);
const findings = audit(records);
return NextResponse.json({ checked: records.length, findings, records });
}
The response returns the findings first, followed by the full records, so you can easily find the problem list while keeping every parsed value available. A request without the header returns a 401 JSON body, which is the behavior to confirm before you add a secret.
4. Run the audit locally with curl
Set the secret and a default URL list in .env.local, then call the route from a second terminal. I start with two published URLs, a static page and one CMS item, to compare their title and description values.
Generate the secret with this OpenSSL command:
openssl rand -hex 32
You now have the long random value needed for the local audit secret.
Add the variables to .env.local in the project root, with the generated value in place of the placeholder:
AUDIT_SECRET=replace-with-a-long-random-string
AUDIT_URLS=https://example.com/,https://example.com/blog/first-post
Restart the dev server if it was already running.
Test the route handler
Trigger the route with an explicit list so the body path is tested:
curl -s -X POST http://localhost:3000/api/meta-audit \
-H "content-type: application/json" \
-H "x-audit-secret: replace-with-a-long-random-string" \
-d '{"urls":["https://example.com/","https://example.com/blog/first-post"]}'
A missing or wrong header returns a 401 JSON body, and an empty URL list returns a 400 with "No URLs supplied".
If the two example URLs returned the values shown below, including a shared description, the synthetic response would be shaped like this:
{
"checked": 2,
"findings": [
{
"url": "https://example.com/blog/first-post",
"rule": "description-duplicate",
"detail": "Same description as https://example.com/"
}
],
"records": [
{
"url": "https://example.com/",
"status": 200,
"title": "Example Co",
"description": "Example Co helps teams ship.",
"canonical": "https://example.com/",
"ogTitle": "Example Co",
"ogDescription": "Example Co helps teams ship."
},
{
"url": "https://example.com/blog/first-post",
"status": 200,
"title": "First post | Example Co",
"description": "Example Co helps teams ship.",
"canonical": "https://example.com/blog/first-post",
"ogTitle": "First post | Example Co",
"ogDescription": "Example Co helps teams ship."
}
]
}
Read the findings array first, and the records array only when a finding needs context. This synthetic example shows a possible pattern. The post has its own title but shares the homepage description, which could mean the collection template page is emitting a fixed string instead of the item's own field.
Drop the body from the curl and rerun; the handler should fall back to AUDIT_URLS and audit the same URLs, which confirms both input paths before you deploy.
5. Fix the findings in the Designer and the CMS
I fix static pages one at a time in their page settings, and collection pages once on the template so every item inherits the correction. The distinction decides where you spend your afternoon.
A title-long on the pricing page is a single edit, while a description-duplicate across forty posts is a single edit on the template plus a check that each item actually has text in the field the template now reads from.
Webflow's on-page SEO settings hold the title and description for both kinds of page; the template page is the one place where those values should draw from the item's fields rather than a typed string.
You now have the two static-page fields identified before you switch to the collection template.
For a collection, set the template page's SEO title and description to draw from collection fields instead of typed text.
Audit rules and mapping
Each rule the audit emits maps to one place in the product:
| Rule | What it flags | Where to fix it |
|---|---|---|
title-missing, title-long, title-duplicate |
No <title>, a title over the limit, or one shared with another URL |
Static page: its page settings in the Designer. CMS item: the template page's title binding, or the item's own field if the binding is right and the content is not |
description-missing, description-long, description-duplicate |
No description, one over the limit, or one shared across URLs | Same split as titles; a duplicate across many items almost always means the template carries a fixed string instead of a field |
canonical-mismatch |
The canonical href differs from the URL you crawled | Confirm which URL you want indexed; often the crawl list holds a non-canonical variant rather than the page being wrong. Canonicals come from Site settings > SEO > Global canonical tag URL, or a static page's own setting |
og-title-differs, og-description-differs |
Open Graph values diverge from the title or description | Usually intentional for social copy; if not, review the page settings and align the values |
redirect, unreachable |
A 3xx or non-200 response | Remove the URL from the list, or fix the link that led you to it |
| Rule → What it flags → Where to fix it |
|---|
title-missing, title-long, title-duplicate |
No <title>, a title over the limit, or one shared with another URL |
| Static page: its page settings in the Designer. CMS item: the template page's title binding, or the item's own field if the binding is right and the content is not |
description-missing, description-long, description-duplicate |
| No description, one over the limit, or one shared across URLs |
| Same split as titles; a duplicate across many items almost always means the template carries a fixed string instead of a field |
canonical-mismatch |
| The canonical href differs from the URL you crawled |
| Confirm which URL you want indexed; often the crawl list holds a non-canonical variant rather than the page being wrong. Canonicals come from Site settings > SEO > Global canonical tag URL, or a static page's own setting |
og-title-differs, og-description-differs |
| Open Graph values diverge from the title or description |
| Usually intentional for social copy; if not, review the page settings and align the values |
redirect, unreachable |
| A 3xx or non-200 response |
| Remove the URL from the list, or fix the link that led you to it |
You now have a rule-by-rule map from each finding to the place where it should be reviewed.
Work top to bottom through the table: titles and descriptions first, because those are what a search result shows, then canonical and Open Graph. For a collection, open one item after fixing the template and confirm the field the template now reads is populated.
If the published page has no description tag, the next crawl reports description-missing rather than description-duplicate; that change tells you the duplicate is gone even though an item still needs copy.
Publish the site when the pass is done. Rerun the local curl and review the findings array for items that still need work.
6. Deploy to Webflow Cloud and rerun on the live mount path
Push the project to its repository and create a Webflow Cloud environment for the site. Use the repository for the environment and choose the app mount path; the app lives at a path on the site instead of at the root, and if the site runs on a custom domain, the plan must be Premium or higher.
I mount the app at /audit; the live curl below assumes that path.
Then add AUDIT_SECRET as a secret and AUDIT_URLS as a plain variable.
Test the deployed audit endpoint
Once the build completes, call the route on the live host, including the mount path in the URL:
curl -s -X POST https://www.example.com/audit/api/meta-audit \
-H "x-audit-secret: replace-with-a-long-random-string"
The empty body makes the handler fall back to AUDIT_URLS from the environment, which is the shape a scheduled caller will use. What bounds a crawl is the platform's 20-second request timeout, not CPU: the route spends its time waiting on fetches.
That is why it accepts at most 100 URLs per request and gives each fetch 8 seconds; split a bigger site into several requests.
The response should match the local run, including a 401 when the header is wrong, and the build log should show no sign of the secret's value.
From here, the audit runs from the same hosting as the site it checks, and any teammate with the secret can rerun it after a publish without a local checkout.
What causes a Webflow Cloud metadata audit to fail?
Failures usually trace to one of four causes: the Next.js edge runtime directive, a crawl list full of redirecting URL variants, canonical tags that point at a different host than the one crawled, and client requests missing the mount path.
The first and last show up as build or request errors on Webflow Cloud; the middle two show up as findings that look wrong but trace back to the crawl list.
Webflow Cloud build fails on an API route that runs fine under next dev
Cause: The word "edge" refers to two different things, and Webflow's own docs use both on one page. Webflow Cloud runs your app on Cloudflare Workers, an edge platform, and the bring-your-own-app page describes that as "Webflow Cloud deploys your app using the Edge runtime."
The same page then recommends adding the Next.js edge runtime directive to API routes. That directive is the Next.js edge runtime target, a separate thing that the OpenNext Cloudflare adapter Webflow Cloud deploys through does not support. A developer who follows the page adds the line and ships a broken build.
Fix: I delete the runtime export from app/api/meta-audit/route.ts and from any other route where it was copied in. Your handler already runs on the edge platform by virtue of being deployed to Webflow Cloud; it does not need, and cannot use, the Next.js runtime target to get there.
If you want a guardrail against a teammate reintroducing it, a grep for runtime = 'edge' in a pre-commit hook is enough. Rebuild, and the route that failed to compile should deploy and answer the live curl.
Every URL comes back as a redirect finding
Cause: The crawl list holds URL variants the site redirects away from, such as http instead of https, or a trailing slash the site removes. The bare domain instead of www causes the same miss. Because fetchOne requests each URL with redirect: 'manual', the 3xx response is what the audit sees.
It reports a redirect finding and never parses the page the redirect points to.
Fix: I rebuild the list from the exact published URLs, using the same host and the same trailing-slash form the site's canonical tags use. Copying the canonical href out of one page's source is a quick way to confirm that form, provided the site outputs one: Webflow only writes canonical tags when Site settings > SEO > Global canonical tag URL is set, or when a static page has its own canonical value.
Rerun the curl, and the records array should show status 200 for each URL, with titles and descriptions parsed.
canonical-mismatch flagged on correct pages
Cause: The crawl may have hit a non-canonical host, such as a staging subdomain. Webflow builds each page's canonical from the global canonical setting plus the page's slug, so a page served on another host still points at the custom domain.
If that host serves the page with a 200 while the page's canonical points at the custom domain, the audit parses the page and flags the difference.
The strip() helper in lib/meta-audit.ts removes only a trailing slash before comparing, so a slash difference alone passes while a host difference still flags.
Fix: I crawl the canonical host. To confirm the cause, open view-source on the crawled URL and compare the href in its <link rel="canonical"> tag with the host you crawled; if only the host differs, the mismatch may come from the crawl host rather than the page settings.
Treat a mismatch as a list problem first and a page problem second. Rebuild the list from the canonical URLs, rerun, and open the page settings only if the flag survives.
Requests to /api/meta-audit return 404 on the deployed app
Cause: The app is mounted at a path on the site. Webflow Cloud injects that base path at build time, but client-side fetch calls are not rewritten. The bring-your-own-app page states it directly: "Client-side fetch calls must manually include the base path."
Locally there is no mount path, so a fetch to /api/meta-audit resolves; deployed, the same relative URL misses the mount. Any report page you add that calls the handler from the browser will hit this, and so will a bookmarked curl that was written against localhost.
Fix: I prefix every client-side request path with process.env.NEXT_PUBLIC_BASE_PATH. Set NEXT_PUBLIC_BASE_PATH in the Webflow Cloud environment to the same mount path the app is deployed at, and leave it unset locally, falling back to an empty string so dev keeps working.
Do not try to read the base path out of next.config: Webflow's configuration page says you don't need to add one, and the value is injected rather than configured. When you call the route by hand, spell the mount path out in full, including /audit.
What you can build next with Webflow Cloud and the Webflow CMS
For collection items, the same Data API reads and updates the fields your template's SEO settings are bound to, with the paged reads the blog export walks through. For deeper customization beyond what the crawl covers, Webflow's developer docs describe the Data API for pages and collection items.
Frequently asked questions
Can I protect the audit route with Next.js middleware instead of a header check in the handler?
Yes. Keep it in middleware.ts. Webflow Cloud's framework-customization docs state: "Node.js runtime middleware isn't supported. Only Edge runtime middleware works on the Workers runtime." Keep the file as middleware.ts rather than renaming it to proxy.ts as Next 16 suggests; proxy runs on the Node runtime, cannot opt into Edge, and the renamed file never runs on Webflow Cloud.
Will a large crawl hit Webflow Cloud's limits?
It will if you send it in one request. Each request has 20 seconds and up to 1,000 subrequests, the same on every plan, so the route caps a request at 100 URLs and each fetch at 8 seconds. Split a large site into several requests of 100 and merge the findings.
Can the audit run on a schedule?
Yes. You can use the empty-body POST, which falls back to AUDIT_URLS, so any scheduler that can send a POST with the x-audit-secret header can call the live route. Set the environment URL list and secret first; a cron job running the live curl from step 6 is the simplest version.
Does the crawl skip noindex pages or respect robots.txt?
No. You should remove noindex URLs from the list when their findings are noise. fetchOne requests every URL it is given and does not read robots.txt rules or the robots meta tag, so the URL list determines which published pages the crawl checks and which findings appear in the report.





