skills/seo-launch-ready/SKILL.md
--- name: seo-launch-ready description: SEO groundwork for a freshly-shipped web app. Generates sitemap.xml, robots.txt, per-route canonical URLs, JSON-LD structured data, and a complete OG/Twitter meta set. Use right after `/ro:cf-ship` or `/ro:fly-deploy` as part of `/ro:app-polish` check #5. category: quality-review argument-hint: [--type <webapp|article|product|video>] [--domain <url>] [--sitemap-source <file>] allowed-tools: Bash(*) Read Write Edit Glob Grep content-pipeline: - pipeline:r
npx skillsauth add RonanCodes/ronan-skills skills/seo-launch-readyInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Everything Google and social crawlers look for on day one. Getting the basics right at launch is cheap; retrofitting after indexing has stabilised is expensive.
/ro:seo-launch-ready # detect stack, full pass
/ro:seo-launch-ready --type article # article-shaped JSON-LD
/ro:seo-launch-ready --domain https://example.com # set canonical origin
/ro:seo-launch-ready --sitemap-source puzzles.json # build sitemap from data
Before scaffolding anything, check what's already there. If all five core pieces exist, stop and report "SEO already wired" instead of dumping the recipe. The user can pass --force to re-scaffold.
test -f "$REPO/public/robots.txt" && echo "✓ robots.txt"
test -f "$REPO/public/sitemap.xml" || \
find "$REPO/src/routes" -iname 'sitemap*.ts' -o -iname 'sitemap*.tsx' 2>/dev/null | grep -q . || \
test -f "$REPO/app/sitemap.ts" && echo "✓ sitemap (static or dynamic)"
grep -qE "rel=\"canonical\"|rel: 'canonical'" "$REPO"/src/routes/__root.* 2>/dev/null && echo "✓ canonical link"
grep -qE "application/ld\+json" "$REPO"/src/routes/__root.* 2>/dev/null && echo "✓ JSON-LD"
grep -qE "og:title|property: 'og:title'" "$REPO"/src/routes/__root.* 2>/dev/null && echo "✓ OG meta"
Report each as ✓ / ✗. If 4+ of 5 are ✓, say "SEO already wired; nothing to scaffold. Audit: [list]. Use --force to re-scaffold or pick the missing piece by hand." Skip the rest of the steps.
If partially wired (1-3 of 5 ✓), tell the user which pieces exist and ask whether to fill the gaps or re-scaffold from scratch.
A common case worth calling out: a dynamic sitemap route (src/routes/sitemap[.]xml.ts for TanStack Start, app/sitemap.ts for Next.js App Router) is just as valid as a static public/sitemap.xml. Don't flag it as missing.
public/robots.txt with sitemap URL.public/sitemap.xml (static) or src/routes/sitemap[.]xml.ts (dynamic for data-driven sites).src/routes/__root.tsx + per-route overrides.robots.txtUser-agent: *
Allow: /
# Block admin / draft routes
Disallow: /admin
Disallow: /_/
Sitemap: https://example.com/sitemap.xml
Put behind public/robots.txt. Always include the absolute sitemap URL — crawlers don't infer it.
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://example.com/</loc><priority>1.0</priority></url>
<url><loc>https://example.com/how-it-works</loc><priority>0.7</priority></url>
<url><loc>https://example.com/pricing</loc><priority>0.9</priority></url>
</urlset>
TanStack Start route:
// src/routes/sitemap[.]xml.ts
import { createFileRoute } from '@tanstack/react-router'
import { db } from '@/lib/db'
export const Route = createFileRoute('/sitemap.xml')({
server: {
handlers: {
GET: async () => {
const puzzles = await db.query.puzzles.findMany({
columns: { date: true },
orderBy: (p, { desc }) => desc(p.date),
limit: 365,
})
const urls = [
{ loc: 'https://example.com/', priority: 1.0 },
{ loc: 'https://example.com/how-it-works', priority: 0.7 },
...puzzles.map((p) => ({
loc: `https://example.com/?date=${p.date}`,
lastmod: p.date,
priority: 0.8,
})),
]
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls
.map(
(u) => ` <url>
<loc>${u.loc}</loc>
${u.lastmod ? `<lastmod>${u.lastmod}</lastmod>` : ''}
<priority>${u.priority}</priority>
</url>`,
)
.join('\n')}
</urlset>`
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=3600, s-maxage=86400',
},
})
},
},
},
})
Cap at ~50k URLs per sitemap. If you exceed it, switch to a sitemap index (sitemap_index.xml) listing sub-sitemaps.
Per-route canonical prevents duplicate-content penalties (e.g. ?source=pwa tracking suffix).
In src/routes/__root.tsx:
head: () => ({
links: [
{ rel: 'canonical', href: 'https://example.com' + location.pathname },
],
}),
For data-driven routes (/posts/$slug), override in the file-based route:
head: ({ loaderData }) => ({
links: [
{ rel: 'canonical', href: `https://example.com/posts/${loaderData.slug}` },
],
meta: [...],
}),
Pick one primary schema per page type. Google supports many; pick the narrowest fit.
Homepage — WebApplication (good for tool-shaped apps):
// src/routes/__root.tsx
scripts: [
{
type: 'application/ld+json',
children: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'Connections Helper',
url: 'https://example.com',
description: 'NYT Connections puzzle sidekick with hints and definitions.',
applicationCategory: 'GameApplication',
operatingSystem: 'Web',
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
// Author identity: pull from your siteConfig / env so the skill works
// for any project. Personal example: 'Ronan Connolly' / 'https://ronanconnolly.dev'.
author: { '@type': 'Person', name: siteConfig.author.name, url: siteConfig.author.url },
}),
},
],
Article page — Article:
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "The article title",
"datePublished": "2026-04-20",
"dateModified": "2026-04-21",
"author": { "@type": "Person", "name": "Ronan Connolly" },
"image": "https://example.com/og/article-slug.png"
}
Product page — Product (if selling anything):
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Product name",
"description": "...",
"image": "...",
"offers": { "@type": "Offer", "price": "29", "priceCurrency": "USD" }
}
FAQ section — FAQPage (/how-it-works, /pricing often qualify):
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Is this free?",
"acceptedAnswer": { "@type": "Answer", "text": "Yes, completely." }
}
]
}
Rich results trigger on questions that exist as visible h3/dt in the page. Don't invent FAQs for the schema; it's a mismatch Google penalises.
Minimum viable for social + search:
meta: [
{ title: 'Connections Helper — NYT puzzle sidekick' },
{ name: 'description', content: 'Hints and definitions for today\'s NYT Connections puzzle.' },
// Open Graph
{ property: 'og:title', content: 'Connections Helper' },
{ property: 'og:description', content: 'Hints and definitions for today\'s NYT Connections.' },
{ property: 'og:image', content: 'https://example.com/api/og?title=Connections+Helper' },
{ property: 'og:url', content: 'https://example.com' },
{ property: 'og:type', content: 'website' },
{ property: 'og:site_name', content: 'Connections Helper' },
// Twitter
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:site', content: '@yourhandle' },
{ name: 'twitter:creator', content: '@yourhandle' },
{ name: 'twitter:title', content: 'Connections Helper' },
{ name: 'twitter:description', content: 'Hints and definitions for today\'s puzzle.' },
{ name: 'twitter:image', content: 'https://example.com/api/og?title=Connections+Helper' },
],
Title length: 50-60 characters before Google truncates. Description: 150-160 characters. OG image: 1200×630.
Each data-driven route should override title + description + OG image with route-specific values. Generic site-wide defaults on /posts/$slug is a missed opportunity:
head: ({ loaderData }) => ({
meta: [
{ title: `${loaderData.title} — Connections Helper` },
{ name: 'description', content: loaderData.excerpt ?? 'Default' },
{ property: 'og:title', content: loaderData.title },
{ property: 'og:image', content: `/api/og?title=${encodeURIComponent(loaderData.title)}` },
{ name: 'twitter:image', content: `/api/og?title=${encodeURIComponent(loaderData.title)}` },
],
}),
Pairs with /ro:og-image-dynamic.
# Local
curl -sL https://example.com/robots.txt
curl -sL https://example.com/sitemap.xml | head -40
# Structured data
# Google Rich Results Test: https://search.google.com/test/rich-results
# Schema.org validator: https://validator.schema.org/
# Meta tags
# OG debugger: https://developers.facebook.com/tools/debug/
# Twitter card validator: https://cards-dev.twitter.com/validator
# LinkedIn post inspector: https://www.linkedin.com/post-inspector/
Submit to Search Console after the sitemap is live. Add the sitemap.xml URL; indexing usually starts within 24-48h.
/ to /en or similar on initial load. Crawlers follow 301s fine but lose internal link equity each hop.?utm= variants, or admin routes.robots.txt path is fixed. /robots.txt, not /meta/robots.txt. Same for sitemap, unless you list it in robots.txt.noindex on staging. Add <meta name="robots" content="noindex"> conditional on import.meta.env.PROD === false or a staging hostname check. A staged leak can take weeks to de-index.utm_source=google / referrer: google.com segments in PostHog (/ro:posthog)./guides/nyt-connections-strategy).noindex them and move on./ro:og-image-dynamic — per-URL OG images referenced in the meta block/ro:app-polish — umbrella; this is check #5/ro:posthog — track search-referred traffic + conversion/ro:cf-ship / /ro:fly-deploy — where the deploy sets the canonical hostnamedevelopment
Close the loop on a Linear ticket when its work ships - move the status and post a deploy comment with the PR link, what shipped, and a try-it link, mentioning the collaborator. Used as the tail of /ro:linear-nightshift for every merged mirror, or manually after an ad-hoc build. Triggers on "linear update", "update the linear ticket", "mark NUT-x done", "tell eoin it shipped", "/ro:linear-update".
devops
Run a night-shift against a collaborator's Linear board. Pulls the team's Grilled tickets (/ro:linear-grill moves a ticket to Grilled once its questions are answered), VERIFIES the questions were actually answered (unanswered → bounce the ticket to the "Question for <name>" state), mirrors verified tickets to ephemeral GitHub issues with ready-for-agent, then runs the standard /ro:night-shift machinery on GitHub. Tail-calls /ro:linear-update for everything that merged + deployed. Triggers on "linear nightshift", "nightshift linear", "drain the linear board", "run the shift off linear", "/ro:linear-nightshift".
development
Grill a collaborator's Linear tickets and move every processed ticket to where it belongs. Resolves the board from the repo's .ro-linear.json, reads the collaborator's Backlog / Ready-for-agent issues, then per ticket either posts 3-5 decision-extracting questions (state moves to "Question for <name>") or confirms it build-ready (state moves to "Grilled", the gate /ro:linear-nightshift consumes); shipped-and-confirmed tickets close as Done. The async-collaborator counterpart of /ro:day-shift for people who never touch GitHub. Triggers on "grill linear", "grill eoin's tickets", "linear grill", "add questions to the linear tickets", "/ro:linear-grill".
development
--- name: about-page description: Add a standard About page to any web app, what it is, the tech stack, and an FAQ, wired into a footer link with a sticky footer. Built with Spartan + Tailwind (the canonical component layer) and falls back to semantic HTML so it ships reliably. Use whenever building, polishing, or shipping an app, every app should have one. Triggers on "add an about page", "about page", "footer about link", or as a standard step in app build/polish. category: frontend argument-h