on-site-seo
Use this skill when implementing on-page SEO fixes in code - meta tags, title tags, heading structure, internal linking, image optimization, semantic HTML, Open Graph and Twitter card tags, and framework-specific SEO patterns. Covers Next.js Metadata API and generateMetadata, Nuxt useSeoMeta, Astro SEO patterns, and Remix meta function. Triggers on any hands-on code task to improve a page's on-site SEO signals.
marketing seoon-site-seometa-tagsog-tagsheadingsinternal-linkingsemantic-htmlWhat is on-site-seo?
Use this skill when implementing on-page SEO fixes in code - meta tags, title tags, heading structure, internal linking, image optimization, semantic HTML, Open Graph and Twitter card tags, and framework-specific SEO patterns. Covers Next.js Metadata API and generateMetadata, Nuxt useSeoMeta, Astro SEO patterns, and Remix meta function. Triggers on any hands-on code task to improve a page's on-site SEO signals.
on-site-seo
on-site-seo is a production-ready AI agent skill for claude-code, gemini-cli, openai-codex, and 1 more. Implementing on-page SEO fixes in code - meta tags, title tags, heading structure, internal linking, image optimization, semantic HTML, Open Graph and Twitter card tags, and framework-specific SEO patterns.
Quick Facts
| Field | Value |
|---|---|
| Category | marketing |
| Version | 0.1.0 |
| Platforms | claude-code, gemini-cli, openai-codex, mcp |
| License | MIT |
How to Install
- Make sure you have Node.js installed on your machine.
- Run the following command in your terminal:
npx skills add AbsolutelySkilled/AbsolutelySkilled --skill on-site-seo- The on-site-seo skill is now available in your AI coding agent (Claude Code, Gemini CLI, OpenAI Codex, etc.).
Overview
On-site SEO is the practice of optimizing individual page elements in code to improve search visibility. This skill is the hands-on implementation companion to the broader SEO strategy skills - it covers everything a developer touches directly: meta tags, headings, images, links, semantic HTML, and social sharing tags. It is framework-aware, with concrete code patterns for Next.js, Nuxt, Astro, and Remix. The focus is on correct, production-grade implementation - not strategy or keyword research.
Tags
seo on-site-seo meta-tags og-tags headings internal-linking semantic-html
Platforms
- claude-code
- gemini-cli
- openai-codex
- mcp
Related Skills
Pair on-site-seo with these complementary skills:
Frequently Asked Questions
What is on-site-seo?
Use this skill when implementing on-page SEO fixes in code - meta tags, title tags, heading structure, internal linking, image optimization, semantic HTML, Open Graph and Twitter card tags, and framework-specific SEO patterns. Covers Next.js Metadata API and generateMetadata, Nuxt useSeoMeta, Astro SEO patterns, and Remix meta function. Triggers on any hands-on code task to improve a page's on-site SEO signals.
How do I install on-site-seo?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill on-site-seo in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support on-site-seo?
This skill works with claude-code, gemini-cli, openai-codex, mcp. Install it once and use it across any supported AI coding agent.
Maintainers
Generated from AbsolutelySkilled
SKILL.md
On-Site SEO
On-site SEO is the practice of optimizing individual page elements in code to improve search visibility. This skill is the hands-on implementation companion to the broader SEO strategy skills - it covers everything a developer touches directly: meta tags, headings, images, links, semantic HTML, and social sharing tags. It is framework-aware, with concrete code patterns for Next.js, Nuxt, Astro, and Remix. The focus is on correct, production-grade implementation - not strategy or keyword research.
When to use this skill
Trigger this skill when the user:
- Wants to add or fix meta tags (title, description, canonical, robots)
- Needs to implement Open Graph or Twitter Card tags
- Asks about heading structure (H1, H2, H3 hierarchy) on a page
- Wants to add or improve alt text on images
- Asks how to implement SEO in Next.js, Nuxt, Astro, or Remix
- Needs to optimize images for SEO (alt text, lazy loading, dimensions, format)
- Wants to add semantic HTML to improve page structure
- Asks about internal linking strategy in code
Do NOT trigger this skill for:
- Keyword research or content strategy - use
keyword-researchskill instead - Performance metrics, Largest Contentful Paint, or Core Web Vitals optimization -
use
core-web-vitalsskill instead
Key principles
Title tag is the single most impactful on-page element - Keep it under 60 characters, put the primary keyword near the start, make it unique per page. Every page with a missing or duplicated title tag is leaving ranking signal on the table.
One H1 per page, containing the primary keyword - The H1 is the page's editorial headline. More than one H1 confuses search engines about the page's topic. H1 should be distinct from the title tag - not identical - but semantically aligned.
Every image needs descriptive alt text - Alt text is read by screen readers and indexed by crawlers. Describe the image's subject and context. Do not keyword- stuff alt text - "golden retriever puppy on grass" beats "dog puppy dog pictures dogs".
Internal links distribute authority and aid discovery - Every page on the site should be reachable via internal links. Anchor text should be descriptive, not generic ("see pricing" not "click here"). Use absolute URLs for reliability.
Semantic HTML helps search engines understand page structure - Elements like
<article>,<nav>,<main>,<section>,<header>, and<footer>communicate document structure to crawlers without extra markup. Use native elements before adding schema markup.
Core concepts
The on-page SEO hierarchy
Search engines weight on-page signals in this order (highest to lowest impact):
title tag <- URL bar, search snippet title, primary ranking signal
H1 <- editorial headline, should contain primary keyword
meta description <- search snippet body, not a ranking signal but drives CTR
headings (H2-H6) <- content structure, secondary keyword placement
body content <- relevance signals, LSI keywords, readability
images <- alt text, filename, lazy loading, dimensions
internal links <- anchor text, page authority distribution, crawl pathsMeta robots directives
Control crawl behavior with the robots meta tag:
<!-- Default - index the page, follow links -->
<meta name="robots" content="index, follow">
<!-- Block indexing but follow links (e.g. pagination, filtered views) -->
<meta name="robots" content="noindex, follow">
<!-- Block indexing and link following (e.g. admin pages) -->
<meta name="robots" content="noindex, nofollow">
<!-- Allow indexing but don't follow links -->
<meta name="robots" content="index, nofollow">Open Graph protocol
OG tags control how pages appear when shared on Facebook, LinkedIn, Slack, and most social platforms. The minimum required set:
<meta property="og:title" content="Page Title Here">
<meta property="og:description" content="Description (max 300 chars recommended)">
<meta property="og:image" content="https://example.com/og-image.png">
<meta property="og:url" content="https://example.com/page">
<meta property="og:type" content="website">OG image should be 1200x630px (1.91:1 ratio). Twitter uses twitter: prefixed tags
but falls back to OG tags when Twitter-specific tags are absent.
Canonical URLs
The canonical tag tells search engines which URL is the authoritative version of a page. Required for: paginated content, filtered/sorted product listings, content syndicated across multiple URLs, and HTTPS/HTTP or www/non-www variants.
<link rel="canonical" href="https://example.com/the-original-page">Semantic HTML5 and SEO value
| Element | SEO signal |
|---|---|
<article> |
Self-contained content unit - good for blog posts, news items |
<main> |
Primary page content - signals to crawlers where the content is |
<nav> |
Navigation landmark - helps crawlers map site structure |
<section> |
Thematic grouping with a heading - creates content hierarchy |
<aside> |
Supplementary content - lower priority to crawlers |
<header> / <footer> |
Page or section framing - not primary content |
<time datetime=""> |
Machine-readable date - helps with freshness signals |
Common tasks
1. Set up complete meta tags for a page
The minimum complete set for any page:
<head>
<!-- Title tag - unique per page, primary keyword near start, max 60 chars -->
<title>Primary Keyword - Brand Name</title>
<!-- Meta description - not a ranking signal but drives CTR, max 160 chars -->
<meta name="description" content="Clear description of what this page offers.">
<!-- Canonical - prevents duplicate content issues -->
<link rel="canonical" href="https://example.com/page-url">
<!-- Robots - only needed when deviating from default (index, follow) -->
<meta name="robots" content="index, follow">
</head>2. Implement Open Graph and Twitter Card tags
<!-- Open Graph (Facebook, LinkedIn, Slack, iMessage previews) -->
<meta property="og:title" content="Page Title">
<meta property="og:description" content="Page description, max 300 chars.">
<meta property="og:image" content="https://example.com/images/og-1200x630.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Description of the OG image">
<meta property="og:url" content="https://example.com/page">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Brand Name">
<!-- Twitter Card (falls back to OG if twitter: tags are absent) -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@twitterhandle">
<meta name="twitter:title" content="Page Title">
<meta name="twitter:description" content="Page description.">
<meta name="twitter:image" content="https://example.com/images/twitter-1200x628.png">
<meta name="twitter:image:alt" content="Description of the Twitter image">3. Structure headings correctly
Every page needs exactly one H1. Headings should never skip levels (H1 > H3).
<main>
<h1>Primary Keyword - Page Main Topic</h1>
<section>
<h2>First Major Subtopic</h2>
<p>Content...</p>
<h3>Supporting Detail Under Subtopic</h3>
<p>Content...</p>
</section>
<section>
<h2>Second Major Subtopic</h2>
<p>Content...</p>
</section>
</main>Anti-pattern to avoid: using heading tags for visual styling. Use CSS classes instead.
4. Optimize images for SEO
<!-- Full SEO-optimized image tag -->
<img
src="/images/golden-retriever-puppy.webp"
alt="Golden retriever puppy playing in grass at sunset"
width="800"
height="600"
loading="lazy"
decoding="async"
>
<!-- For above-the-fold images: eager loading + fetchpriority -->
<img
src="/images/hero-banner.webp"
alt="Team working in a modern office space"
width="1440"
height="600"
loading="eager"
fetchpriority="high"
>Image SEO rules:
- Filename should describe content:
golden-retriever-puppy.webpnotimg_0042.jpg - Always include
widthandheightto prevent layout shift (CLS) - Use modern formats: WebP or AVIF preferred over JPEG/PNG
loading="lazy"on below-fold images;loading="eager"on above-fold
5. Build internal linking patterns
<!-- Good: descriptive anchor text, absolute URL -->
<a href="https://example.com/pricing">View our pricing plans</a>
<!-- Good: contextual link in body content -->
<p>
Learn more about
<a href="/guides/seo-strategy">technical SEO strategy</a>
before optimizing individual pages.
</p>
<!-- Bad: generic anchor text -->
<a href="/pricing">click here</a>
<!-- Breadcrumb navigation - also useful for SEO -->
<nav aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/guides">Guides</a></li>
<li aria-current="page">On-Site SEO</li>
</ol>
</nav>6. Add semantic HTML to page structure
<body>
<header>
<nav aria-label="Main navigation">
<!-- Primary site navigation -->
</nav>
</header>
<main>
<article>
<header>
<h1>Article Title</h1>
<time datetime="2025-03-14">March 14, 2025</time>
</header>
<section>
<h2>Section Heading</h2>
<p>Section content...</p>
</section>
</article>
<aside>
<h2>Related Articles</h2>
<!-- Supplementary content -->
</aside>
</main>
<footer>
<!-- Site footer -->
</footer>
</body>7. Next.js App Router - generateMetadata
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await fetchPost(params.slug);
return {
title: post.title,
description: post.excerpt,
alternates: {
canonical: `https://example.com/blog/${params.slug}`,
},
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.ogImage, width: 1200, height: 630 }],
type: 'article',
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.ogImage],
},
};
}8. Nuxt 3 - useSeoMeta
// pages/blog/[slug].vue
<script setup>
const route = useRoute();
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`);
useSeoMeta({
title: post.value.title,
description: post.value.excerpt,
ogTitle: post.value.title,
ogDescription: post.value.excerpt,
ogImage: post.value.ogImage,
ogUrl: `https://example.com/blog/${route.params.slug}`,
twitterCard: 'summary_large_image',
twitterTitle: post.value.title,
twitterDescription: post.value.excerpt,
twitterImage: post.value.ogImage,
});
</script>Anti-patterns / common mistakes
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| Multiple H1 tags | Signals ambiguous topic to crawlers; dilutes keyword focus | Exactly one H1 per page containing the primary keyword |
| Missing canonical | Creates duplicate content issues when URLs differ (www vs non-www, trailing slashes) | Add canonical to every page, always pointing to the preferred URL |
| Title tag over 60 chars | Google truncates it in search results, reducing CTR | Keep title under 60 chars; put important keywords first |
| Meta description over 160 chars | Truncated in SERPs; the extra text wastes space | Keep meta description under 155-160 chars |
| Generic alt text | "image.jpg" or "photo" provides zero signal | Describe the image subject and context specifically |
| "Click here" anchor text | Provides no keyword context to crawlers | Use descriptive anchor text: "view pricing plans", "read the SEO guide" |
| Missing OG image | Unfurled links show no preview - kills CTR on social | Every page needs a 1200x630px OG image |
| Missing image dimensions | Causes Cumulative Layout Shift, hurts CLS score | Always include width and height attributes |
| Heading tags for styling | Uses <h3> because it "looks right" visually |
Use CSS classes for visual sizing; use headings for document structure only |
| Identical meta descriptions | Duplicate descriptions across pages dilute uniqueness | Write unique, page-specific descriptions for every page |
| noindex on important pages | Accidentally blocking indexation of content pages | Audit robots meta tags and verify Search Console coverage |
Gotchas
Next.js generateMetadata does not merge with layout.tsx metadata - If a root
layout.tsxdefines ametadataexport and a page also usesgenerateMetadata, they do not deep-merge automatically. The page's metadata overrides the layout's at the same key. You must spread the parent metadata explicitly or usemetadata.metadataBaseat the root level to avoid broken absolute URL generation for OG images.Canonical self-referencing with trailing slash mismatch causes duplicate content - If the server serves both
/pageand/page/, and the canonical points to only one form, crawlers still see two URLs. Enforce a consistent URL form at the CDN or server level AND set canonical - don't rely on canonical alone to resolve the conflict.OG image URL must be absolute, not relative -
og:imagecontent must be a fullhttps://URL. Relative paths like/images/og.pngare not followed by most social crawlers. Always include the full domain, including in framework configurations like Next.jsmetadataBase.noindex on paginated pages blocks discovery of linked content - Adding
noindexto?page=2and beyond is a common SEO mistake. Crawlers still follow links from those pages to discover new URLs. The correct approach for pagination is using<link rel="canonical">pointing to page 1 or letting Google handle pagination discovery naturally.title tag set in JavaScript renders too late for crawlers - Client-side JS that sets
document.titleafter load is not reliably indexed by crawlers or used for social previews. Always set title tags server-side in the<head>, never viadocument.title = ...after hydration.
References
For detailed framework-specific SEO patterns, load the relevant reference file:
references/nextjs-seo.md- Next.js App Router Metadata API, generateMetadata, sitemap.ts, robots.ts, dynamic OG images with next/ogreferences/nuxt-seo.md- Nuxt 3 useSeoMeta, useHead, nuxt-seo module, OG image generation, sitemap and robots modulesreferences/astro-seo.md- Astro SEO component patterns, content collections with frontmatter SEO, sitemap integration, astro-seo packagereferences/remix-seo.md- Remix meta function (V2 convention), loader-based dynamic meta, parent route meta merging, canonical URLs
For related skills:
- Load
schema-markupskill for JSON-LD structured data implementation - Load
core-web-vitalsskill for LCP, CLS, INP performance optimization - Load
technical-seoskill for crawlability, rendering strategy, and site architecture
References
astro-seo.md
Astro SEO Reference
Astro's architecture is inherently SEO-friendly: it ships zero JavaScript by default, renders HTML on the server, and provides excellent tools for meta management. This reference covers the complete SEO implementation pattern for Astro sites.
SEO Component Pattern
Astro does not have a built-in metadata API - the idiomatic pattern is a reusable
SEO.astro component (or Head.astro) that accepts props and renders all head tags.
---
// src/components/SEO.astro
export interface Props {
title: string;
description: string;
canonical?: string;
ogImage?: string;
ogType?: 'website' | 'article';
noindex?: boolean;
publishedAt?: string;
updatedAt?: string;
}
const {
title,
description,
canonical = Astro.url.href,
ogImage = '/og/default.png',
ogType = 'website',
noindex = false,
publishedAt,
updatedAt,
} = Astro.props;
const siteUrl = 'https://acme.com';
const absoluteOgImage = ogImage.startsWith('http')
? ogImage
: `${siteUrl}${ogImage}`;
---
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
<!-- Open Graph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={absoluteOgImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:url" content={canonical} />
<meta property="og:type" content={ogType} />
<meta property="og:site_name" content="Acme Corp" />
{ogType === 'article' && publishedAt && (
<meta property="article:published_time" content={publishedAt} />
)}
{ogType === 'article' && updatedAt && (
<meta property="article:modified_time" content={updatedAt} />
)}
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={absoluteOgImage} />
<meta name="twitter:image:alt" content={`${title} preview image`} />
<meta name="twitter:site" content="@acmecorp" />Base Layout Usage
Include the SEO component in your base layout's <head>:
---
// src/layouts/BaseLayout.astro
import SEO from '../components/SEO.astro';
export interface Props {
title: string;
description: string;
ogImage?: string;
noindex?: boolean;
}
const { title, description, ogImage, noindex } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<SEO
title={title}
description={description}
ogImage={ogImage}
noindex={noindex}
/>
</head>
<body>
<slot />
</body>
</html>Content Collections - SEO Frontmatter
Define a Zod schema for your content collection to enforce SEO fields.
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string().max(60, 'Title should be under 60 characters'),
description: z.string().max(160, 'Description should be under 160 characters'),
publishedAt: z.date(),
updatedAt: z.date().optional(),
ogImage: z.string().optional(),
noindex: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
});
export const collections = { blog };---
# src/content/blog/image-seo-guide.md
title: 'How to Optimize Images for SEO'
description: 'A complete guide to image alt text, formats, and lazy loading for better search rankings.'
publishedAt: 2025-03-14
ogImage: '/images/image-seo-og.png'
tags: ['seo', 'images', 'performance']
---
# How to Optimize Images for SEODynamic Page with Content Collections
---
// src/pages/blog/[slug].astro
import { getCollection, getEntry } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.noindex);
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<BaseLayout
title={post.data.title}
description={post.data.description}
ogImage={post.data.ogImage}
>
<article>
<h1>{post.data.title}</h1>
<time datetime={post.data.publishedAt.toISOString()}>
{post.data.publishedAt.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<Content />
</article>
</BaseLayout>astro-seo Package
The astro-seo package provides a comprehensive SEO component as an alternative
to building your own. Install with npm install astro-seo.
---
// src/layouts/BaseLayout.astro
import { SEO } from 'astro-seo';
---
<head>
<SEO
title="Page Title | Acme Corp"
description="Page description here."
canonical="https://acme.com/page"
openGraph={{
basic: {
title: 'Page Title',
type: 'website',
image: 'https://acme.com/og/page.png',
url: 'https://acme.com/page',
},
image: {
width: 1200,
height: 630,
alt: 'Descriptive alt text for OG image',
},
optional: {
description: 'Page description here.',
siteName: 'Acme Corp',
},
}}
twitter={{
card: 'summary_large_image',
site: '@acmecorp',
creator: '@authorhandle',
}}
/>
</head>Sitemap Integration
Use @astrojs/sitemap integration for automatic sitemap generation.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://acme.com',
integrations: [
sitemap({
// Exclude specific pages
filter: (page) =>
!page.includes('/admin/') && !page.includes('/private/'),
// Custom page options
customPages: ['https://acme.com/special-landing'],
// Change frequency and priority overrides
changefreq: 'weekly',
priority: 0.7,
lastmod: new Date(),
// Serialize for per-page control
serialize: (item) => {
if (item.url === 'https://acme.com/') {
return { ...item, priority: 1.0, changefreq: 'daily' };
}
return item;
},
}),
],
});Astro Image - next/image Equivalent
Use @astrojs/image or the built-in <Image> component (Astro 3+) for optimized images.
---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---
<!-- Local image - type-safe, automatic optimization -->
<Image
src={heroImage}
alt="Team of engineers collaborating in an open office"
width={1200}
height={600}
format="webp"
quality={85}
loading="eager"
/>
<!-- Remote image - requires explicit dimensions -->
<Image
src="https://cdn.acme.com/blog/post-hero.jpg"
alt="Screenshot showing the Acme dashboard product interface"
width={800}
height={450}
format="webp"
loading="lazy"
/>
<!-- Responsive image with sizes -->
<Image
src={heroImage}
alt="Acme product hero"
widths={[400, 800, 1200]}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
format="webp"
/>JSON-LD Structured Data
Inject JSON-LD directly in the <head> using a <script> tag.
---
// src/pages/blog/[slug].astro
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.data.title,
description: post.data.description,
image: `https://acme.com${post.data.ogImage}`,
datePublished: post.data.publishedAt.toISOString(),
dateModified: (post.data.updatedAt ?? post.data.publishedAt).toISOString(),
author: {
'@type': 'Person',
name: post.data.author,
url: `https://acme.com/authors/${post.data.authorSlug}`,
},
publisher: {
'@type': 'Organization',
name: 'Acme Corp',
logo: {
'@type': 'ImageObject',
url: 'https://acme.com/logo.png',
},
},
};
---
<head>
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
</head>View Transitions and SEO
Astro's View Transitions API can affect how search engines index your site. Ensure the site works correctly without JavaScript enabled (for crawlers).
---
// src/layouts/BaseLayout.astro
import { ViewTransitions } from 'astro:transitions';
---
<head>
<!-- ViewTransitions is progressive enhancement - crawlers see full SSR HTML -->
<ViewTransitions />
<!-- SEO tags are always server-rendered regardless of transitions -->
<SEO title={title} description={description} />
</head>Key consideration: meta tags in <head> are updated on each transition by Astro.
Ensure your SEO component renders the correct tags for the current page in both
initial load and transition scenarios.
RSS Feed
Astro generates RSS feeds via @astrojs/rss. Create src/pages/rss.xml.ts and
use getCollection to map posts to feed items. Add the autodiscovery link in <head>:
<link rel="alternate" type="application/rss+xml" title="Acme Corp Blog" href="/rss.xml" />Checklist for a new Astro page
- SEO component included with title (under 60 chars), description (under 160 chars)
- Canonical URL set (defaults to
Astro.url.hrefin the component above) - All OG and Twitter tags populated with correct image (1200x630px)
-
<Image>component used for all images with descriptivealttext - One
<h1>per page containing the primary keyword - JSON-LD added for blog posts, product pages, FAQ pages
- Page appears in sitemap (not filtered by the sitemap
filterfunction) - Content collection schema enforces
titleanddescriptionmax lengths -
noindex: trueset in frontmatter for private or duplicate content pages
nextjs-seo.md
Next.js SEO Reference
Next.js App Router provides a first-class Metadata API that generates head tags server-side with full TypeScript support. This reference covers the complete pattern set for production Next.js SEO.
Metadata API - Static
For pages with fixed metadata, export a metadata object from any page.tsx
or layout.tsx. This is the simplest and most common pattern.
// app/about/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About Us - Acme Corp',
description: 'Learn about Acme Corp\'s mission, team, and story.',
alternates: {
canonical: 'https://acme.com/about',
},
openGraph: {
title: 'About Us - Acme Corp',
description: 'Learn about Acme Corp\'s mission, team, and story.',
url: 'https://acme.com/about',
siteName: 'Acme Corp',
images: [
{
url: 'https://acme.com/og/about.png',
width: 1200,
height: 630,
alt: 'Acme Corp team photo',
},
],
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'About Us - Acme Corp',
description: 'Learn about Acme Corp\'s mission, team, and story.',
images: ['https://acme.com/og/about.png'],
site: '@acmecorp',
},
};Metadata API - Title Templates
Use title templates in layout.tsx to avoid repeating the brand name in every
page's metadata export.
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: {
template: '%s | Acme Corp',
default: 'Acme Corp',
},
description: 'Acme Corp makes the world\'s best widgets.',
};
// app/blog/page.tsx - title becomes "Blog | Acme Corp"
export const metadata: Metadata = {
title: 'Blog',
description: 'Latest articles from the Acme Corp team.',
};
// app/blog/[slug]/page.tsx - use generateMetadata for dynamic titlegenerateMetadata - Dynamic Pages
For pages where metadata depends on fetched data (blog posts, product pages, etc.),
use the generateMetadata async function.
// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';
type Props = {
params: { slug: string };
};
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const post = await fetchPost(params.slug);
// Optionally merge with parent metadata
const previousImages = (await parent).openGraph?.images || [];
return {
title: post.title,
description: post.excerpt,
alternates: {
canonical: `https://acme.com/blog/${params.slug}`,
},
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author.name],
images: [
{
url: post.ogImage || 'https://acme.com/og/default.png',
width: 1200,
height: 630,
},
...previousImages,
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.ogImage],
},
};
}Note: generateMetadata and the page component share the same fetch call - Next.js
deduplicates fetches automatically via the cache mechanism.
Robots meta tag
// app/admin/page.tsx - block indexing for private routes
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
nocache: true,
},
};
// app/search/page.tsx - noindex dynamic search results pages
export const metadata: Metadata = {
robots: {
index: false,
follow: true,
},
};sitemap.ts
Generate a dynamic sitemap from your content. Place at app/sitemap.ts.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await fetchAllPosts();
const postEntries: MetadataRoute.Sitemap = posts.map((post) => ({
url: `https://acme.com/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'weekly',
priority: 0.8,
}));
return [
{
url: 'https://acme.com',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 1,
},
{
url: 'https://acme.com/about',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.7,
},
...postEntries,
];
}For large sites (>50k URLs), use multiple sitemaps with a sitemap index:
// app/blog-sitemap.xml/route.ts
export async function GET() {
const posts = await fetchAllPosts();
const xml = generateSitemapXml(posts);
return new Response(xml, {
headers: { 'Content-Type': 'application/xml' },
});
}robots.ts
Control crawl access at the site level. Place at app/robots.ts.
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/admin/', '/api/', '/private/'],
},
],
sitemap: 'https://acme.com/sitemap.xml',
};
}JSON-LD Structured Data
Next.js does not have a built-in JSON-LD API. Inject it via a <script> tag in
the page or layout component.
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetchPost(params.slug);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
image: post.ogImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.author.name,
url: `https://acme.com/authors/${post.author.slug}`,
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{/* page content */}</article>
</>
);
}For site-wide schema (Organization, WebSite with sitelinks searchbox), add the
<script> to app/layout.tsx.
next/image for SEO
The next/image component automatically handles WebP/AVIF conversion, responsive
srcset, lazy loading, and prevents layout shift. Always specify width and height
or use fill with a sized container.
import Image from 'next/image';
// Fixed size image
<Image
src="/images/hero.jpg"
alt="Team of engineers working together"
width={1200}
height={600}
priority // use for above-the-fold images (replaces loading="eager")
/>
// Fill container (responsive)
<div style={{ position: 'relative', width: '100%', height: '400px' }}>
<Image
src="/images/banner.jpg"
alt="Product banner showing widget in use"
fill
sizes="100vw"
style={{ objectFit: 'cover' }}
/>
</div>
// Responsive with sizes attribute
<Image
src="/images/product.webp"
alt="Acme Pro Widget in matte black"
width={800}
height={600}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>next/font - Eliminate Font CLS
Font loading causes Cumulative Layout Shift. next/font inlines font CSS and
prevents FOUT by preloading fonts automatically.
// app/layout.tsx
import { Inter, Merriweather } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
const merriweather = Merriweather({
subsets: ['latin'],
weight: ['400', '700'],
display: 'swap',
variable: '--font-merriweather',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${merriweather.variable}`}>
<body>{children}</body>
</html>
);
}Dynamic OG Images with next/og
Generate OG images programmatically using the ImageResponse API from next/og.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export const alt = 'Blog post open graph image';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function OgImage({ params }: { params: { slug: string } }) {
const post = await fetchPost(params.slug);
return new ImageResponse(
(
<div
style={{
background: '#0f172a',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '80px',
}}
>
<p style={{ color: '#64748b', fontSize: 28, margin: 0 }}>Acme Blog</p>
<h1 style={{ color: '#f8fafc', fontSize: 64, margin: '16px 0' }}>
{post.title}
</h1>
<p style={{ color: '#94a3b8', fontSize: 32, margin: 0 }}>
{post.author.name}
</p>
</div>
),
size
);
}Place opengraph-image.tsx (or .png, .jpg) in the same folder as page.tsx -
Next.js automatically links it as the OG image for that route.
Checklist for a new Next.js page
-
metadataexport orgenerateMetadatafunction with title and description -
alternates.canonicalset to the canonical URL -
openGraphwith title, description, image (1200x630), url, type -
twitterwith card, title, description, images -
robotsset if the page should be noindexed (admin, private, search results) - All
<Image>components have descriptivealttext - One
<h1>per page matching the page's primary keyword - JSON-LD added for article pages, product pages, or FAQ pages
- Page appears in
sitemap.tsoutput
nuxt-seo.md
Nuxt 3 SEO Reference
Nuxt 3 provides composables (useSeoMeta, useHead) and the @nuxtjs/seo module
suite for complete on-site SEO implementation. This reference covers the core patterns
for production Nuxt 3 SEO.
useSeoMeta - Recommended Approach
useSeoMeta is the recommended composable for setting all SEO meta tags. It provides
full TypeScript support and is XSS-safe (unlike raw useHead with arbitrary tags).
// pages/about.vue
<script setup lang="ts">
useSeoMeta({
title: 'About Us - Acme Corp',
description: 'Learn about Acme Corp\'s mission, team, and story.',
ogTitle: 'About Us - Acme Corp',
ogDescription: 'Learn about Acme Corp\'s mission, team, and story.',
ogImage: 'https://acme.com/og/about.png',
ogImageWidth: 1200,
ogImageHeight: 630,
ogUrl: 'https://acme.com/about',
ogType: 'website',
ogSiteName: 'Acme Corp',
twitterCard: 'summary_large_image',
twitterTitle: 'About Us - Acme Corp',
twitterDescription: 'Learn about Acme Corp\'s mission, team, and story.',
twitterImage: 'https://acme.com/og/about.png',
twitterSite: '@acmecorp',
});
</script>useSeoMeta - Reactive / Dynamic Pages
Use computed refs or reactive values to update meta when data changes. Nuxt handles
the reactivity automatically.
// pages/blog/[slug].vue
<script setup lang="ts">
const route = useRoute();
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`);
useSeoMeta({
title: () => post.value?.title ?? 'Blog Post',
description: () => post.value?.excerpt ?? '',
ogTitle: () => post.value?.title ?? 'Blog Post',
ogDescription: () => post.value?.excerpt ?? '',
ogImage: () => post.value?.ogImage ?? 'https://acme.com/og/default.png',
ogUrl: () => `https://acme.com/blog/${route.params.slug}`,
ogType: 'article',
articlePublishedTime: () => post.value?.publishedAt,
articleModifiedTime: () => post.value?.updatedAt,
twitterCard: 'summary_large_image',
twitterImage: () => post.value?.ogImage ?? 'https://acme.com/og/default.png',
});
</script>useHead - Full Control
For cases where useSeoMeta doesn't cover a tag (canonical, structured data, etc.),
use useHead directly.
// pages/blog/[slug].vue
<script setup lang="ts">
const route = useRoute();
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`);
useHead({
link: [
{
rel: 'canonical',
href: `https://acme.com/blog/${route.params.slug}`,
},
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.value?.title,
description: post.value?.excerpt,
datePublished: post.value?.publishedAt,
dateModified: post.value?.updatedAt,
author: {
'@type': 'Person',
name: post.value?.author?.name,
},
}),
},
],
});
</script>nuxt.config.ts - Site-wide SEO defaults
Set default meta values in nuxt.config.ts. These are inherited by all pages and
can be overridden with useSeoMeta or useHead.
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
htmlAttrs: { lang: 'en' },
title: 'Acme Corp',
meta: [
{ name: 'description', content: 'Acme Corp makes the world\'s best widgets.' },
{ property: 'og:site_name', content: 'Acme Corp' },
{ name: 'twitter:site', content: '@acmecorp' },
],
link: [
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
],
},
},
});definePageMeta - Robots / Layout Control
Use definePageMeta to set route-level options. For SEO, use it to control the
layout context - actual meta tags should still use useSeoMeta.
// pages/admin/dashboard.vue
<script setup lang="ts">
definePageMeta({
layout: 'admin',
});
useSeoMeta({
robots: 'noindex, nofollow',
});
</script>Title Templates
Use titleTemplate in app.vue or a layout to apply a consistent brand suffix
across all pages.
// app.vue
<script setup lang="ts">
useHead({
titleTemplate: (title) => title ? `${title} | Acme Corp` : 'Acme Corp',
});
</script>Then each page only needs to set the page-specific part:
// pages/pricing.vue
<script setup lang="ts">
useSeoMeta({ title: 'Pricing' }); // renders as "Pricing | Acme Corp"
</script>@nuxtjs/seo Module Suite
The @nuxtjs/seo meta-module installs and configures several SEO-related modules.
Install with npx nuxi module add seo.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/seo'],
site: {
url: 'https://acme.com',
name: 'Acme Corp',
description: 'Acme Corp makes the world\'s best widgets.',
defaultLocale: 'en',
},
// Robots configuration (via nuxt-robots)
robots: {
disallow: ['/admin', '/private'],
},
// Sitemap configuration (via nuxt-simple-sitemap)
sitemap: {
strictNuxtContentPaths: true,
},
});The suite includes: nuxt-robots, nuxt-simple-sitemap, nuxt-og-image,
nuxt-schema-org, nuxt-link-checker, and nuxt-seo-ui.
nuxt-simple-sitemap
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-simple-sitemap'],
sitemap: {
// For dynamic routes - provide all URLs
urls: async () => {
const posts = await fetchAllPosts();
return posts.map((post) => ({
loc: `/blog/${post.slug}`,
lastmod: post.updatedAt,
changefreq: 'weekly',
priority: 0.8,
}));
},
// Exclude routes
exclude: ['/admin/**', '/api/**'],
},
});nuxt-og-image - Automatic OG Image Generation
nuxt-og-image generates OG images from Vue components at build time or on-demand.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-og-image'],
ogImage: {
fonts: ['Inter:400', 'Inter:700'],
},
});// pages/blog/[slug].vue
<script setup lang="ts">
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`);
// Define OG image using the built-in template
defineOgImage({
component: 'BlogPost', // references OG image component
title: post.value?.title,
description: post.value?.excerpt,
author: post.value?.author?.name,
});
</script>// components/OgImage/BlogPost.vue
<template>
<div class="w-full h-full bg-slate-900 flex flex-col justify-center p-20">
<p class="text-slate-400 text-2xl mb-4">Acme Blog</p>
<h1 class="text-white text-6xl font-bold">{{ title }}</h1>
<p class="text-slate-300 text-3xl mt-6">{{ author }}</p>
</div>
</template>
<script setup lang="ts">
defineProps<{ title: string; description: string; author: string }>();
</script>NuxtImg - Optimized Images
@nuxt/image provides automatic format conversion, resizing, and srcset generation.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/image'],
image: {
// Providers for external images
domains: ['cdn.acme.com'],
// Default image quality
quality: 80,
},
});<!-- pages/blog/[slug].vue -->
<NuxtImg
:src="post.heroImage"
:alt="post.heroImageAlt"
width="1200"
height="630"
format="webp"
loading="eager"
sizes="sm:100vw md:100vw lg:1200px"
/>
<!-- Lazy loaded image below the fold -->
<NuxtImg
:src="post.thumbnail"
:alt="post.thumbnailAlt"
width="400"
height="300"
format="webp"
loading="lazy"
/>Nuxt Content - SEO with Markdown
When using @nuxt/content, define SEO fields in frontmatter and access them in
the page component.
---
title: 'How to Optimize Images for SEO'
description: 'A complete guide to image SEO including alt text, formats, and lazy loading.'
ogImage: '/images/image-seo-guide.png'
publishedAt: '2025-03-14'
updatedAt: '2025-03-14'
---
# How to Optimize Images for SEO// pages/blog/[...slug].vue
<script setup lang="ts">
const { data: page } = await useAsyncData(
route.path,
() => queryContent(route.path).findOne()
);
useSeoMeta({
title: page.value?.title,
description: page.value?.description,
ogTitle: page.value?.title,
ogDescription: page.value?.description,
ogImage: page.value?.ogImage ?? 'https://acme.com/og/default.png',
ogUrl: `https://acme.com${route.path}`,
articlePublishedTime: page.value?.publishedAt,
articleModifiedTime: page.value?.updatedAt,
});
</script>Checklist for a new Nuxt 3 page
-
useSeoMetawith title, description, and all OG/Twitter properties -
useHeadwith canonical link pointing to the preferred URL - Title uses
titleTemplatefromapp.vue(page-specific part only) - All
<NuxtImg>components have descriptivealttext - One
<h1>per page containing the primary keyword - Page included in sitemap via
nuxt-simple-sitemapor manualurlsconfig - OG image defined (1200x630px) via
defineOgImageor static image -
robots: 'noindex'set on admin, private, and duplicate content pages
remix-seo.md
Remix SEO Reference
Remix handles SEO through its meta export function and loader pattern. Unlike
Next.js or Nuxt, Remix merges meta from all matched routes - parent route meta
flows down to child routes unless overridden. This reference covers the complete
SEO implementation pattern for Remix v2 (and Remix with Vite).
meta Function - V2 Convention
In Remix v2, the meta function receives the loader data and must return an array
of meta descriptor objects.
// app/routes/about.tsx
import type { MetaFunction } from '@remix-run/node';
export const meta: MetaFunction = () => {
return [
{ title: 'About Us | Acme Corp' },
{ name: 'description', content: 'Learn about Acme Corp\'s mission, team, and story.' },
{ tagName: 'link', rel: 'canonical', href: 'https://acme.com/about' },
// Open Graph
{ property: 'og:title', content: 'About Us | Acme Corp' },
{ property: 'og:description', content: 'Learn about Acme Corp\'s mission, team, and story.' },
{ property: 'og:image', content: 'https://acme.com/og/about.png' },
{ property: 'og:image:width', content: '1200' },
{ property: 'og:image:height', content: '630' },
{ property: 'og:url', content: 'https://acme.com/about' },
{ property: 'og:type', content: 'website' },
{ property: 'og:site_name', content: 'Acme Corp' },
// Twitter Card
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: 'About Us | Acme Corp' },
{ name: 'twitter:description', content: 'Learn about Acme Corp\'s mission, team, and story.' },
{ name: 'twitter:image', content: 'https://acme.com/og/about.png' },
{ name: 'twitter:site', content: '@acmecorp' },
];
};Loader-Based Dynamic Meta
Pass data from the loader to the meta function via the data argument.
// app/routes/blog.$slug.tsx
import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
export async function loader({ params }: LoaderFunctionArgs) {
const post = await fetchPost(params.slug!);
if (!post) throw new Response('Not Found', { status: 404 });
return json({ post });
}
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data) {
return [
{ title: 'Post Not Found | Acme Corp' },
{ name: 'robots', content: 'noindex' },
];
}
const { post } = data;
return [
{ title: `${post.title} | Acme Blog` },
{ name: 'description', content: post.excerpt },
{ tagName: 'link', rel: 'canonical', href: `https://acme.com/blog/${post.slug}` },
// Open Graph - Article type
{ property: 'og:title', content: post.title },
{ property: 'og:description', content: post.excerpt },
{ property: 'og:image', content: post.ogImage ?? 'https://acme.com/og/default.png' },
{ property: 'og:type', content: 'article' },
{ property: 'og:url', content: `https://acme.com/blog/${post.slug}` },
{ property: 'article:published_time', content: post.publishedAt },
{ property: 'article:modified_time', content: post.updatedAt },
// Twitter
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: post.title },
{ name: 'twitter:description', content: post.excerpt },
{ name: 'twitter:image', content: post.ogImage ?? 'https://acme.com/og/default.png' },
];
};
export default function BlogPost() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
{/* content */}
</article>
);
}Merging Meta with Parent Routes
Remix merges meta from matched routes. Without explicit merging, child route meta
completely replaces parent meta. To merge, access parent matches data.
// app/root.tsx - root meta (applies to all routes)
export const meta: MetaFunction = () => [
{ property: 'og:site_name', content: 'Acme Corp' },
{ name: 'twitter:site', content: '@acmecorp' },
];
// app/routes/blog.$slug.tsx - merge with parent
export const meta: MetaFunction<typeof loader, { root: typeof rootLoader }> = ({
data,
matches,
}) => {
// Get root-level meta to merge
const rootMeta = matches.find((m) => m.id === 'root')?.meta ?? [];
// Filter out tags the child route will override
const inheritedMeta = rootMeta.filter(
(meta) =>
!('title' in meta) &&
!('property' in meta && meta.property === 'og:title') &&
!('name' in meta && meta.name === 'description')
);
return [
...inheritedMeta,
{ title: `${data?.post.title} | Acme Blog` },
{ name: 'description', content: data?.post.excerpt },
{ property: 'og:title', content: data?.post.title },
// ... rest of tags
];
};Root Layout - Default Meta and JSON-LD
Set site-wide defaults in app/root.tsx. Inject JSON-LD for site-level schema here.
// app/root.tsx
import type { MetaFunction } from '@remix-run/node';
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from '@remix-run/react';
export const meta: MetaFunction = () => [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ property: 'og:site_name', content: 'Acme Corp' },
{ name: 'twitter:site', content: '@acmecorp' },
];
const websiteJsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Acme Corp',
url: 'https://acme.com',
potentialAction: {
'@type': 'SearchAction',
target: { '@type': 'EntryPoint', urlTemplate: 'https://acme.com/search?q={search_term_string}' },
'query-input': 'required name=search_term_string',
},
};
export default function App() {
return (
<html lang="en">
<head>
<Meta />
<Links />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
/>
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}Canonical URLs in Remix
Canonical URLs require using { tagName: 'link' } in the meta array (not a
separate links export - that's for stylesheets).
export const meta: MetaFunction<typeof loader> = ({ data, location }) => {
// Build canonical from current URL, stripping query params
const canonical = `https://acme.com${location.pathname}`;
return [
{ title: 'Page Title | Acme Corp' },
{ tagName: 'link', rel: 'canonical', href: canonical },
// For paginated content: point canonical to the first page
// { tagName: 'link', rel: 'canonical', href: 'https://acme.com/blog' },
];
};Noindex for Dynamic/Private Routes
// app/routes/search.tsx - noindex search result pages
export const meta: MetaFunction = ({ location }) => [
{ title: `Search Results | Acme Corp` },
{ name: 'robots', content: 'noindex, follow' },
];
// app/routes/admin.tsx - block admin routes
export const meta: MetaFunction = () => [
{ name: 'robots', content: 'noindex, nofollow' },
];handle Convention - Breadcrumbs
Use the handle export to provide structured data for breadcrumb generation.
// app/routes/blog.$slug.tsx
export const handle = {
breadcrumb: (data: ReturnType<typeof useLoaderData>) => ({
label: data.post.title,
to: `/blog/${data.post.slug}`,
}),
};
// app/root.tsx - render breadcrumbs using matches
import { useMatches } from '@remix-run/react';
function Breadcrumbs() {
const matches = useMatches();
const crumbs = matches
.filter((match) => match.handle?.breadcrumb)
.map((match) => match.handle.breadcrumb(match.data));
return (
<nav aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
{crumbs.map((crumb) => (
<li key={crumb.to}>
<a href={crumb.to}>{crumb.label}</a>
</li>
))}
</ol>
</nav>
);
}Sitemap Generation in Remix
Remix does not have a built-in sitemap - generate it via a resource route.
// app/routes/sitemap[.]xml.ts
import type { LoaderFunctionArgs } from '@remix-run/node';
export async function loader({ request }: LoaderFunctionArgs) {
const posts = await fetchAllPosts();
const baseUrl = new URL(request.url).origin;
const staticPages = ['', '/about', '/pricing', '/contact'];
const staticEntries = staticPages
.map(
(path) => `
<url>
<loc>${baseUrl}${path}</loc>
<changefreq>monthly</changefreq>
<priority>${path === '' ? '1.0' : '0.7'}</priority>
</url>`
)
.join('');
const dynamicEntries = posts
.map(
(post) => `
<url>
<loc>${baseUrl}/blog/${post.slug}</loc>
<lastmod>${new Date(post.updatedAt).toISOString().split('T')[0]}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>`
)
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${staticEntries}
${dynamicEntries}
</urlset>`.trim();
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=3600',
},
});
}OG Image Generation - Resource Route
Generate dynamic OG images via a resource route using @vercel/og or satori.
// app/routes/og[.]png.ts
import { ImageResponse } from '@vercel/og';
import type { LoaderFunctionArgs } from '@remix-run/node';
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const title = url.searchParams.get('title') ?? 'Acme Corp';
const description = url.searchParams.get('description') ?? '';
return new ImageResponse(
<div
style={{
background: '#0f172a',
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '80px',
}}
>
<h1 style={{ color: '#f8fafc', fontSize: '64px', margin: '0 0 24px' }}>
{title}
</h1>
<p style={{ color: '#94a3b8', fontSize: '32px', margin: 0 }}>
{description}
</p>
</div>,
{ width: 1200, height: 630 }
);
}Reference the route from the meta function:
// app/routes/blog.$slug.tsx
export const meta: MetaFunction<typeof loader> = ({ data, request }) => {
const baseUrl = new URL(request.url).origin;
const ogImageUrl = `${baseUrl}/og.png?title=${encodeURIComponent(data?.post.title ?? '')}&description=${encodeURIComponent(data?.post.excerpt ?? '')}`;
return [
{ property: 'og:image', content: ogImageUrl },
{ property: 'og:image:width', content: '1200' },
{ property: 'og:image:height', content: '630' },
{ name: 'twitter:image', content: ogImageUrl },
];
};Checklist for a new Remix route
-
metafunction exported with title (under 60 chars), description (under 160 chars) - Canonical URL set via
{ tagName: 'link', rel: 'canonical', href: '...' } - OG tags with title, description, image (1200x630px), url, type
- Twitter card tags with card type, title, description, image
- Loader handles 404 gracefully -
metafunction returns noindex for missing data - All images have descriptive
alttext - One
<h1>in the rendered component matching the primary keyword - Route included in
app/routes/sitemap[.]xml.tsoutput - Parent route meta merged explicitly if site-wide tags are needed
-
noindexset for search results, paginated pages, and admin routes
Frequently Asked Questions
What is on-site-seo?
Use this skill when implementing on-page SEO fixes in code - meta tags, title tags, heading structure, internal linking, image optimization, semantic HTML, Open Graph and Twitter card tags, and framework-specific SEO patterns. Covers Next.js Metadata API and generateMetadata, Nuxt useSeoMeta, Astro SEO patterns, and Remix meta function. Triggers on any hands-on code task to improve a page's on-site SEO signals.
How do I install on-site-seo?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill on-site-seo in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support on-site-seo?
on-site-seo works with claude-code, gemini-cli, openai-codex, mcp. Install it once and use it across any supported AI coding agent.