When: technical + content SEO audit of a Next.js site, pre-delivery or periodic · Input: {{project_path}} {{live_url}} {{target_market}} {{keywords}} {{fix}} {{client_project}} · Output: verdict + findings and measurement table · keywords: seo check, indexing, canonical, SEO audit, core web vitals
0stars
UpdatedOct 30, 2026
v1
License: CC BY 4.0. The author shares the prompts of this repo under these terms.
Import with the CLI:
ple import ple:zeynepaslierhan/ortak-web-en/c8241bd7169 lines
Role: you audit the technical and content SEO of a Next.js site WITH EVIDENCE. Write measurements, not opinions.
Project root: {{#if project_path}}{{project_path}}{{else}}. (working directory){{/if}} · Live: {{#if live_url}}{{live_url}}{{else}}not given → write live measurements as "not measured (no live URL)" and measure locally with `npm run build && npm run start` (base http://localhost:3000); if the build fails on a missing environment variable, write the missing key NAME, never read the value, and mark live sections "not measured"{{/if}}
Target market: {{#if target_market}}{{target_market}} (language, html lang and hreflang are audited against this market){{else}}not given → infer from html lang and content language, state your assumption in the report{{/if}}
{{#if standards_dir}}Deep reference: read index.md in {{standards_dir}} and apply the relevant rule tables too.{{/if}}
## Rules
- Notation: `\|` in tables is a markdown escape; type `|` in the shell (grep patterns are given with `-E` accordingly). `<site>` = {{#if live_url}}{{live_url}}{{else}}http://localhost:3000{{/if}}; `<domain>` = bare domain without www.
- Every finding = ID · Where (file:line or URL) · Measured · Expected/threshold · Severity · Fix (minimal) · Verification command. No finding without evidence.
- Write anything you did not measure as "not measured (reason)". Do not invent numbers beyond the thresholds in this prompt; a threshold tagged "practical" is a direction, not a violation stamp (P3 at most).
- Severity (shared across the pack): P0 critical (live data/account/payment/secret risk; for SEO: site-wide noindex/`Disallow: /` in production or 4xx/5xx to Googlebot — the site drops out of search; now) · P1 high (before delivery) · P2 medium · P3 low.
- On the live site, GET/HEAD only; no form submission, sign-in or payment. Never read or print `.env` or key VALUES; key name + location only.
- Local build: if another audit/agent already started a server in the same root, do not run `next build` again, reuse the running server (parallel builds write to the same `.next` and collide); otherwise run builds sequentially. `npx` tools (lighthouse, linkinator) need install + network: `npx -y`; if they fail to install, "not measured (tool)".
- Google's supported rich result list changes often: the list in this prompt reflects 2026-10; date it in the report and check developers.google.com/search/docs/appearance/structured-data/search-gallery.
- Ownership: security headers/secrets/authorization → «Web security audit (Next.js + Supabase)» (here only what affects SEO, e.g. a WAF blocking Googlebot); alt text, accessibility, JS budget → «Web production baseline audit». Call format: `ple get "<full title>" --var project_path=<root> --var live_url=<url>`; in the full launch gate this report is an input to «Web full audit and launch gate».
## Step 0 — stack detection and URL sample
| Check | How | Record |
|---|---|---|
| Next version and router | `package.json` (next, react); `ls app src/app pages src/pages` | App / Pages / mixed |
| Latest version | `npm view next version` (with date) | installed ↔ latest |
| SEO libraries | `grep -E '"next-sitemap\|next-seo\|next-intl\|next-i18next"' package.json` | which are active |
| Supabase and auth | `@supabase/*` packages; `middleware.ts` (Next ≤15) or `proxy.ts` (Next 16) matcher; if `src/app` exists the file must be inside `src/` (Next docs; one at the root does not run → protected paths may be exposed; check the Proxy/Middleware line in build output) | paths that must stay out of the index |
| Hosting | `vercel.json`, `netlify.toml`, `Dockerfile`; `next.config.*` `redirects` `headers` `trailingSlash` `i18n` | source of redirects and headers |
| i18n | `[locale]` segment, `i18n` config, `alternates.languages` | single / multi-language |
| Payment provider | stripe, lemonsqueezy, iyzico etc. packages and checkout/success paths | paths to keep out of the index |
| Repo ↔ live | `git -C <root> status -sb`; `git -C <root> log --oneline @{u}..`; compare the live commit from the hosting record (e.g. `npx vercel inspect <site>`, needs sign-in) with `git rev-parse HEAD` | if ahead > 0 or live commit ≠ HEAD: "repo is ahead of live; live findings belong to an older version"; if not comparable, "not measured (access)" |
URL sample (8-12): home, 1 listing, 1 detail, 1 blog/article, pricing, legal page, sign-in, 1 protected page, checkout/success, a nonexistent URL (`/nonexistent-page-xyz`), and each language's home page if any. Put checks that do not apply under "Not applicable" (e.g. single language → hreflang).
## A. Indexability
| Check | Measure | Threshold | Violation |
|---|---|---|---|
| robots.txt does not block the site in production | `curl -s <site>/robots.txt`; `app/robots.ts` or `public/robots.txt` | no `Disallow: /`; `Sitemap:` line present; ≤500 KiB | P0 / P2 (no Sitemap line) |
| No noindex leak in production | each URL: `curl -sI URL \| grep -i x-robots-tag`; `curl -s URL \| grep -Eio '<meta name="(robots\|googlebot)"[^>]*>'`; env condition on `robots: { index: false }` in code | noindex on indexable pages = 0 | P0 (site-wide) / P1 (page) |
| Preview/staging blocked | preview URL: PR deploy comment or `npx vercel ls` (needs sign-in; if not found, not measured); `curl -sI https://<preview>.vercel.app \| grep -i x-robots-tag`; on preview/staging with a custom domain the header is not automatic, add it manually | `x-robots-tag: noindex` present or 401/403 (Deployment Protection) | P1 |
| No links from prod to vercel.app | `curl -s <site> \| grep -c 'vercel\.app'`; same search in the sitemap | 0 | P1 |
| Utility pages out of the index | sign-in, account, protected, checkout/success: noindex or redirect to login | crawlable + noindex | P2 |
| Disallow ≠ noindex | for each path Disallowed in robots, look for noindex in body/headers | a path meant to leave the index is crawlable and carries noindex | P3 (not verified — check Google's current block-indexing docs) |
| Googlebot not blocked | `curl -A 'Googlebot' -sI <site>/` → 200; no Googlebot disallow in robots; WAF/bot protection settings (UA test is approximate; say so if you cannot verify by IP) | Googlebot blocks 0 | P0 |
## B. Metadata (App: `metadata`/`generateMetadata` · Pages: `next/head`)
| Check | Measure | Threshold | Violation |
|---|---|---|---|
| Unique title and description | from all sample URLs: `curl -s URL \| grep -o '<title>[^<]*</title>'` → `sort \| uniq -d` empty; same for description | 0% duplicates | P1 |
| Title template | `title: { template, default }` in root layout; brand at start or end with a separator, no repetition | template present | P3 |
| Length (practical) | count characters | title ~50-60 chars; description ~155-160 (mobile ~120) | P3 (not verified — no official limit) |
| metadataBase | `metadataBase: new URL(...)` in root layout; `grep -c localhost` on live HTML | localhost in live HTML = 0 | P1 |
| Fallback ≠ set env | find the variable carrying the site URL: `grep -rnE 'NEXT_PUBLIC_SITE_URL\|NEXT_PUBLIC_APP_URL\|VERCEL_URL\|metadataBase' app src lib 2>/dev/null`; lines with a `??` / `\|\|` fallback are not evidence; `curl -sI` the live og:image and canonical URL → 200 | expected domain; localhost and `*.vercel.app` from `VERCEL_URL` 0 | P1 |
| Metadata in `<head>` (streaming metadata, Next ≥15.2) | if `generateMetadata` awaits, Next appends tags to `<body>`; HTML-limited bots (Twitterbot, Bingbot etc.; NOT Googlebot) get them in `<head>`; run the command below with both UAs; `next.config` `htmlLimitedBots` | canonical and robots meta inside `<head>` for both UAs (Google accepts canonical only in `<head>`) | P1 (canonical/robots outside head in raw HTML) / P3 (title/description only); position in the rendered DOM is not verified — check the "crawled page" HTML of the indexed version in Search Console; the live test, Rich Results Test (Google-InspectionTool) and Lighthouse are in Next's default list and see it in head, so they are not evidence |
Head check (replace `<URL>` with a sample URL): `for ua in 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' 'Twitterbot/1.0'; do echo "$ua"; curl -sL -A "$ua" <URL> | tr -d '\n' | sed 's#</head>.*##' | grep -oE '<title|rel="canonical"|name="(description|robots)"' | sort | uniq -c; done` → a tag missing from head = violation. Fix: `htmlLimitedBots: /.*/` disables streaming (TTFB rises); this setting REPLACES the default bot list, it does not extend it.
## C. Canonical, host, protocol, TLS
| Check | Measure | Threshold | Violation |
|---|---|---|---|
| Self-referencing absolute canonical | `curl -s URL \| grep -io '<link rel="canonical"[^>]*>'`; `alternates.canonical` in App; a fixed canonical in the root layout is inherited by every subpage → in the sample (incl. nonexistent URL, sign-in/utility) count subpages whose canonical points to home | exactly 1 per page, https, equal to the final URL; subpages canonical to home 0 (on 404/utility: no canonical or self) | P1 |
| Single host + single protocol | `for u in http://<domain> http://www.<domain> https://<domain> https://www.<domain>; do curl -sSIL -o /dev/null -w "%{http_code} %{num_redirects} %{url_effective}\n" "$u"; echo "exit $?"; done` | `url_effective` identical for all 4 variants; one-way 301/308 | P1 |
| Consistent trailing slash | `trailingSlash` setting ↔ canonical ↔ sitemap ↔ internal links | one form | P2 |
| TLS on four variants | the `exit` value of the loop above (60 = certificate could not be verified, 35 = TLS handshake error, 0 = OK); `curl -vI https://<domain>` and `https://www.<domain>`; `openssl s_client -connect <domain>:443 -servername <domain> </dev/null \| openssl x509 -noout -text \| grep -A1 'Subject Alternative Name'` | certificate errors 0 on 4/4 variants; SAN covers apex + www | P1 |
| Parameterized URLs | `?utm=`, `?sort=` versions canonical to the clean URL; if an invalid value (`?page=9999`, `?sort=xyz`, unknown `?q=`) returns 200 + same content, check its canonical | clean URL; self-canonical duplicates on invalid parameters 0 (or 404) | P2 |
## D. Status codes and redirects
| Check | Measure | Threshold | Violation |
|---|---|---|---|
| Real 404 (no soft 404) | `curl -sI <site>/nonexistent-page-xyz`; a nonexistent dynamic slug (`/blog/missing-xyz`) — `notFound()` in App, `notFound: true` in Pages; under `loading.tsx`/Suspense, `notFound()` after streaming starts can return 200 + noindex meta → take the status from curl, not from code; a root `app/loading.tsx` wraps every page in Suspense, and a `loading.tsx` in the dynamic page's own or parent segment has the same effect (fix: remove that loading.tsx, wrap only the slow data component in Suspense, or 404 the slug in Proxy) | 404 or 410; not 200 | P1 |
| Old site migration (if any) | old URL list: old sitemap or `curl -s 'https://web.archive.org/cdx/search/cdx?url=<domain>/*&fl=original&collapse=urlkey&filter=statuscode:200&filter=mimetype:text/html&limit=200'`; first response for each `curl -sI -o /dev/null -w "%{http_code} %{redirect_url}\n" "$u"` (301/308 + target; with `-L` the first code is hidden), final state `curl -sIL -o /dev/null -w "%{http_code} %{url_effective}\n" "$u"`; is the old apex↔www direction preserved | every old URL 301/308 to its counterpart or 404/410; bulk redirects to home and SPA shells returning 200 (soft 404) 0 | P2 |
| Permanent moves | `next.config` `redirects` `permanent: true` → 308; `NextResponse.redirect` and `redirect()` default to 307; for permanent use `permanentRedirect()` or status 308 | 301/308 for permanent | P2 |
| No chains | `curl -sIL URL \| grep -ci '^location'` | direct to target (Googlebot follows 10 hops; 3-5 hops is a third-party practical threshold) | P2 |
| Server errors | 5xx on sample URLs | 0 | P1 |
## E. Sitemap
| Check | Measure | Threshold | Violation |
|---|---|---|---|
| Dynamic and current | `app/sitemap.ts` (or next-sitemap); does new content enter automatically; is `lastmod` the real change date (not the build date) | present | P2 |
| Only canonical URLs returning 200 | `curl -s <site>/sitemap.xml \| grep -o '<loc>[^<]*' \| sed 's/<loc>//' \| head -100 \| while read u; do curl -s -o /dev/null -w "%{http_code} $u\n" "$u"; done` (if `<sitemapindex>`, open the child sitemaps the same way first; if over 100, state it is a sample); check canonical and noindex on the first 10 that return 200 with the A/C commands | non-200 = 0; noindex/redirected/canonical-elsewhere URLs = 0 | P1 |
| Limits and host | URL count, size, host | ≤50,000 URLs / 50MB; single host | P2 |
| Linked but not in sitemap | canonical internal URLs returning 200 without noindex from the linkinator crawl in section K − sitemap URL set | missing entries 0 | P3 |
| Declared in robots | `curl -s <site>/robots.txt \| grep -i '^sitemap:'` absolute URL | present | P2 |
## F. hreflang and language (if multi-language; otherwise Not applicable)
| Check | Measure | Threshold | Violation |
|---|---|---|---|
| Reciprocal set | each variant: `curl -s URL \| grep -io 'hreflang="[^"]*"'`; `alternates.languages` in App | all variants share the same set + self-ref; `x-default` optional | P2 |
| Language actually correct | `curl -s URL \| grep -o '<html[^>]*>'` (lang value); sample that the body is in that language | lang = route language = content language{{#if target_market}} (for {{target_market}}){{/if}} | P2 (not verified — check Google's localized versions docs) |
## G. Rendering (indexable content on the server)
| Check | Measure | Threshold | Violation |
|---|---|---|---|
| Main text in HTML | without JS: `curl -s URL \| sed 's/<[^>]*>/ /g' \| grep -c "<a sentence visible on the page>"`; if the match is only in the RSC payload inside `<script>`, do not count it as visible text; in code, `'use client'` at page root + content fetched in `useEffect` | main content present in raw HTML | P1 |
| Crawlable links | navigation via `<a href>` (next/link), not `onClick` redirects | 0 navigation without href | P2 |
## H. Structured data (JSON-LD)
| Check | Measure | Threshold | Violation |
|---|---|---|---|
| Organization + WebSite | `<script type="application/ld+json">` on home; `name`, `url`, `logo` match visible data | present, parse errors 0 | P2 |
| Schema fits the type | by product type: SoftwareApplication / Product / Article-BlogPosting (headline, image, datePublished, author) / BreadcrumbList (ordered `itemListElement`) | recommended fields filled | P2 (Breadcrumb P3) |
| Validation | Rich Results Test (search.google.com/test/rich-results) + validator.schema.org; locally `JSON.parse` | errors 0 | P2 |
| Do not rely on restricted/removed types | `grep -rnE 'FAQPage\|HowTo\|Course\|Quiz\|Dataset' app src components 2>/dev/null` + proposal/report text; types: FAQ, HowTo, Course Info (single course; carousel needs ≥3 courses on the same page), Practice Problem, Dataset (Dataset Search only), Book Actions | the schema is not an error; "will get rich results" promises in report/proposal 0 | P3 |
| Matches visible text | price, rating, name in the schema are visible on the page | mismatches 0 | P2 |
## I. Social cards and icons
| Check | Measure | Threshold | Violation |
|---|---|---|---|
| OG 4 fields | `curl -s URL \| grep -o 'property="og:[^"]*"'` → title, type, image, url; absolute https | 4/4 | P2 |
| OG image size | download outside the repo with `curl -s -o /tmp/og.img <og:image URL>`, `file /tmp/og.img` (for webp etc. `identify` or macOS `sips -g pixelWidth -g pixelHeight`); compare with declared `og:image:width/height`; `opengraph-image.tsx` in App | 1200x630 (1.91:1), min 200x200; declared = actual | P2 (not verified — check Meta's current docs) |
| Twitter card | `grep -o 'name="twitter:[^"]*"'` | `twitter:card` present; if large, ≥1200x628 | P2 |
| Icon set | `grep -Eo 'rel="(icon\|apple-touch-icon\|manifest)"[^>]*'`; `curl -sI` the manifest `href` in HTML (App `app/manifest.ts` → `/manifest.webmanifest`) | favicon; apple-touch-icon 180x180 opaque; manifest 192 + 512; manifest content-type json (not text/html) | P2 |
## J. Core Web Vitals, images, fonts
| Check | Measure | Threshold | Violation |
|---|---|---|---|
| Field data (p75) | PageSpeed Insights API: `curl -s "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=<URL>&strategy=mobile" \| jq '.loadingExperience.metrics'` (encode the URL; keyless quota is low, on 429 not measured); `CUMULATIVE_LAYOUT_SHIFT_SCORE` percentile comes ×100 (10 = 0.10); if there is no URL data, report `originLoadingExperience` as "origin"; if neither, "insufficient field data" | LCP ≤2.5 s · INP ≤200 ms · CLS ≤0.1 (poor: >4 s · >500 ms · >0.25); all three in the good band | poor band P1 · in between P2 |
| Lab | `npx lighthouse <URL> --only-categories=performance --output=json --output-path=/tmp/lh.json --chrome-flags="--headless"` (mobile by default; needs Chrome; locally only against a production build, not `next dev`) | same thresholds; INP is not measured in the lab → report TBT as the proxy | P2 (mark lab ≠ field) |
| Images | `grep -rn '<img ' app src components`; next/image with `width/height` or `fill`+`sizes`; LCP image not lazy, prioritized (`priority`; `preload` in Next 16 — check the next/image docs for the installed version) | images without dimensions 0 | P2 |
| Alt text | see «Web production baseline audit» D4 | — | — |
| Fonts | `next/font` in use; no `fonts.googleapis.com` link/`@import`; `display` value | external font requests 0; no font-caused CLS | P3 |
## K. Content structure{{#if keywords}} and keywords{{/if}}
| Check | Measure | Threshold | Violation |
|---|---|---|---|
| Single H1 | `curl -s URL \| grep -o '<h1' \| wc -l` | 1 (practical; Google does not penalize multiple H1s), report 0 H1 separately | P3 |
| Heading hierarchy | order of `grep -o '<h[1-6]'`; skipped levels | skips 0 | P3 |
| Internal links | orphan pages = sitemap URL set − internal URLs reached by `npx linkinator <site> --recurse --format csv`; "click here" anchor text | orphans 0 | P2 |
| Thin/duplicate content | near-duplicate pages, empty pages with boilerplate text (no official numeric threshold; evidence: word count + example) | — | P3 |
{{#if keywords}}Keywords ({{keywords}}): map each keyword to one target URL; tabulate whether it appears in title, H1, description and URL; 2+ pages targeting the same primary keyword = cannibalization (P3). Rankings and impressions need Search Console → without access, "not measured (access)".{{/if}}
## L. Local and business information (if a physical business; otherwise Not applicable)
- LocalBusiness JSON-LD (name, address, phone) identical to the visible info in the footer/contact page → mismatch P2.
- Google Business Profile status → "not measured (access)".
## M. AI search visibility, snippets, site reputation
| Check | Measure | Threshold | Violation |
|---|---|---|---|
| Official guidance (Google AI features, 2025-12-10) | no extra file/special schema needed: no Googlebot block (A), main content in HTML text (G), schema matches visible text (H) | A, G, H pass | P2 |
| llms.txt | not official for Google, not scored | — | note only "optional, weak evidence"; absence is not a finding |
| Snippet controls | `grep -rnE 'nosnippet\|max-snippet\|data-nosnippet' app src components 2>/dev/null` | undocumented use 0 | P3 |
| Google-Extended | `curl -s <site>/robots.txt \| grep -i -A2 google-extended` | decision (allow/block) written and documented with its owner | P3 |
| Site reputation | is an unsupervised third-party section (coupons, sponsored) indexable on the main domain | none, or noindex | P3 (not verified — check current spam policies) |
## N. Measurement and maintenance (access required)
- RUM: `grep -rnE "web-vitals|@vercel/speed-insights|@vercel/analytics" package.json app src` → if none, P3 (CrUX is insufficient at low traffic; not verified).
- Is the Search Console property verified (`google-site-verification` meta or DNS; ≥ 1 verified owner), was the sitemap submitted ("Success"), coverage and CWV reports → without access, "not measured (access)"; a weekly review is a practical suggestion.
- Broken links: {{#if live_url}}`npx linkinator {{live_url}} --recurse`{{else}}on the local server `npx linkinator http://localhost:3000 --recurse`{{/if}}; broken internal link P2, no periodic crawl P3 (every 3 months; practical, no official threshold).
{{#if fix}}## Fix mode
- Fix P0/P1 with a minimal diff: metadataBase, canonical, noindex leak, robots/sitemap, notFound/404, redirect code, server rendering. No out-of-scope rewrites.
- After each fix: run the relevant curl verification against `npm run build && npm run start`; run tests if present (`npm test`); append the output to the finding row. If verification is red, revert the change and mark the finding "could not fix".
- If the client flag is on, no commit/push (see below). If off, stage only the changed files by explicit path (`git add <path>`; no bulk add); push only with the owner's approval.{{/if}}
{{#if client_project}}Client project: NO commit/push. Report fixes only as a patch (unified diff) or a branch proposal.{{/if}}
## Output (in this order; header with date `date +%F`, commit hash, measured environment)
## Verdict
One sentence + PASS/FAIL. FAIL if there is an open P0 or an open P1 without owner and date (shared pack rule).
## Findings
| ID | Area | Where | Measured | Expected/threshold | Severity | Fix | Verification |
Severity-ordered (P0 → P3). ID format: SEO-<section letter>-<no>; Area = section name.
### Measurement table (under Findings)
| URL | LCP | INP/TBT | CLS | Source (field/lab) |
## Checked and clean
Checks in sections A-N that passed, with the evidence command (do not skip).
## Not measured
Check · reason (access, no live URL, tool error) · how to measure.
## Not applicable
Checks that do not apply per Step 0, with the reason.
## Next 3 steps
{{#if client_project}}## Client summary
Non-technical, max 6 lines: the 3 issues that most affect search visibility and their impact.{{/if}}
Feedback: If this prompt missed a check, gave a wrong threshold or you hit a PLE problem, send one line: ple feedback --type prompt --prompt "Web SEO audit" "<what is missing → what it should be>" (use --type bug or --type missing for PLE issues; if the command is unavailable, add a "PLE feedback:" line at the end of your report).Related prompts
Web security audit (Next.js + Supabase)When: scan for vulnerabilities; pre-delivery or periodic security audit of a Next.js + Supabase app · Input: {{project_path}} {{live_url}} {{scope}} {{fix}} {{client_project}} {{jurisdiction}} · Output: evidence-backed findings table + PASS/FAIL · keywords: RLS, secret leaks, CVE, security headers@zeynepaslierhan0Web production baseline auditWhen: pre-delivery gap sweep or inherited web app (accounts, legal, a11y, backups, monitoring) · Input: {{project_path}} {{live_url}} {{product_type}} {{has_sales}} {{jurisdiction}} {{client_project}} {{fix}} · Output: completeness matrix + top 10 gaps · keywords: production readiness, GDPR, backup@zeynepaslierhan0Web project setup from scratch (Next.js + Supabase)When: new project; scaffold a Next.js + Supabase app or an early-stage one · Input: {{project_path}} {{project_name}} {{product_type}} {{languages}} {{apply}} {{client_project}} {{fix}} {{jurisdiction}} · Output: project card + M0-M5 + DoD · keywords: setup, scaffold, boilerplate, new project@zeynepaslierhan0Web full audit and launch gateWhen: pre-delivery audit, pre-launch check, periodic audit; security+baseline+SEO in one run · Input: {{project_path}} {{live_url}} {{gate}} {{client_project}} {{fix}} {{jurisdiction}} · Output: PASS/FAIL + merged priority list · keywords: launch checklist, production readiness, release gate@zeynepaslierhan0