seo-technical
π―Skillfrom andrehfp/tinyplate
Optimizes Next.js apps with comprehensive technical SEO infrastructure, generating sitemaps, robots.txt, meta tags, structured data, and canonical URLs.
Part of
andrehfp/tinyplate(8 items)
Installation
git clone git@github.com:andrehfp/tinyplate.gitSkill Details
Implement technical SEO infrastructure for Next.js apps. Use this skill when setting up sitemaps, robots.txt, meta tags, OpenGraph, structured data (JSON-LD), canonical URLs, and other technical SEO elements. Covers Next.js 15/16 App Router patterns and 2025 best practices.
Overview
# Technical SEO Implementation (Next.js 2025)
Skill Files
This skill includes multiple reference files:
- SKILL.md (this file): Core technical SEO implementation guide
- nextjs-implementation.md: Next.js-specific code templates and patterns
- checklist.md: Pre-launch technical SEO checklist
- structured-data.md: JSON-LD schema markup templates
What This Skill Covers
- Sitemaps β
app/sitemap.tsfor dynamic sitemap generation - Robots.txt β
app/robots.tsfor crawler directives - Meta Tags β OpenGraph, Twitter Cards, keywords, descriptions
- Structured Data β JSON-LD for rich snippets
- Canonical URLs β Prevent duplicate content issues
- Performance SEO β Core Web Vitals considerations
---
# Part 1: Sitemap Implementation
Next.js App Router Sitemap (app/sitemap.ts)
Next.js automatically serves /sitemap.xml when you create app/sitemap.ts:
```typescript
import type { MetadataRoute } from "next";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://example.com";
export default function sitemap(): MetadataRoute.Sitemap {
const currentDate = new Date().toISOString();
// Static pages
const staticPages: MetadataRoute.Sitemap = [
{
url: BASE_URL,
lastModified: currentDate,
changeFrequency: "weekly",
priority: 1.0,
},
{
url: ${BASE_URL}/pricing,
lastModified: currentDate,
changeFrequency: "monthly",
priority: 0.8,
},
{
url: ${BASE_URL}/about,
lastModified: currentDate,
changeFrequency: "monthly",
priority: 0.7,
},
{
url: ${BASE_URL}/privacy,
lastModified: currentDate,
changeFrequency: "yearly",
priority: 0.3,
},
{
url: ${BASE_URL}/terms,
lastModified: currentDate,
changeFrequency: "yearly",
priority: 0.3,
},
];
return staticPages;
}
```
Dynamic Sitemap with Database Content
```typescript
import type { MetadataRoute } from "next";
import { db } from "@/lib/db";
import { blogPosts, products } from "@/lib/db/schema";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://example.com";
export default async function sitemap(): Promise
// Fetch dynamic content
const posts = await db.select().from(blogPosts).where(eq(blogPosts.published, true));
const allProducts = await db.select().from(products);
const staticPages: MetadataRoute.Sitemap = [
{ url: BASE_URL, lastModified: new Date(), changeFrequency: "weekly", priority: 1.0 },
];
const blogPages: MetadataRoute.Sitemap = posts.map((post) => ({
url: ${BASE_URL}/blog/${post.slug},
lastModified: post.updatedAt || post.createdAt,
changeFrequency: "weekly" as const,
priority: 0.7,
}));
const productPages: MetadataRoute.Sitemap = allProducts.map((product) => ({
url: ${BASE_URL}/products/${product.slug},
lastModified: product.updatedAt,
changeFrequency: "daily" as const,
priority: 0.8,
}));
return [...staticPages, ...blogPages, ...productPages];
}
```
Large Sitemaps (50,000+ URLs)
Use generateSitemaps() for sitemap index:
```typescript
import type { MetadataRoute } from "next";
const URLS_PER_SITEMAP = 50000;
export async function generateSitemaps() {
const totalProducts = await getProductCount();
const sitemapCount = Math.ceil(totalProducts / URLS_PER_SITEMAP);
return Array.from({ length: sitemapCount }, (_, i) => ({ id: i }));
}
export default async function sitemap({ id }: { id: number }): Promise
const start = id * URLS_PER_SITEMAP;
const products = await getProducts({ start, limit: URLS_PER_SITEMAP });
return products.map((product) => ({
url: ${BASE_URL}/products/${product.slug},
lastModified: product.updatedAt,
}));
}
```
Sitemap Best Practices
| Practice | Why |
|----------|-----|
| Keep lastModified accurate | Google uses it when consistently accurate |
| Only include canonical URLs | Duplicates waste crawl budget |
| Priority: 1.0 homepage, 0.8-0.9 key pages, 0.6-0.7 others | Guides crawler importance |
| changeFrequency is ignored by Google | Include for other search engines |
| Max 50,000 URLs per sitemap | Use sitemap index for more |
---
# Part 2: Robots.txt Implementation
Next.js App Router Robots (app/robots.ts)
```typescript
import type { MetadataRoute } from "next";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://example.com";
export default function robots(): MetadataRoute.Robots {
const isProduction = process.env.NODE_ENV === "production";
// Block everything in non-production
if (!isProduction) {
return {
rules: { userAgent: "*", disallow: "/" },
};
}
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/api/",
"/dashboard/",
"/admin/",
"/private/",
"/_next/",
"/sign-in/",
"/sign-up/",
],
},
// Block AI training bots (optional)
{ userAgent: "GPTBot", disallow: "/" },
{ userAgent: "ChatGPT-User", disallow: "/" },
{ userAgent: "CCBot", disallow: "/" },
{ userAgent: "anthropic-ai", disallow: "/" },
{ userAgent: "Google-Extended", disallow: "/" },
],
sitemap: ${BASE_URL}/sitemap.xml,
};
}
```
Robots.txt Rules
| Directive | Usage |
|-----------|-------|
| User-agent: * | Applies to all crawlers |
| Allow: / | Allow crawling of path |
| Disallow: /private/ | Block crawling of path |
| Sitemap: | Advertise sitemap location |
| Crawl-delay: | Slow down crawling (not respected by Google) |
Common AI Bots to Block/Allow
```typescript
// Block AI training (keeps content out of training data)
{ userAgent: "GPTBot", disallow: "/" }, // OpenAI
{ userAgent: "ChatGPT-User", disallow: "/" }, // ChatGPT browsing
{ userAgent: "CCBot", disallow: "/" }, // Common Crawl
{ userAgent: "anthropic-ai", disallow: "/" }, // Anthropic
{ userAgent: "Google-Extended", disallow: "/" }, // Google AI training
{ userAgent: "Bytespider", disallow: "/" }, // ByteDance
// Allow AI search (keeps content in AI search results)
// Comment out the above to allow AI indexing
```
What NOT to Block
- Don't block
/sitemap.xml - Don't block CSS/JS files (
/_next/static/) - Don't block images you want indexed
- Don't block your homepage
---
# Part 3: Metadata Implementation
Root Layout Metadata (app/layout.tsx)
```typescript
import type { Metadata, Viewport } from "next";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://example.com";
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "#6366f1",
};
export const metadata: Metadata = {
metadataBase: new URL(BASE_URL),
// Title template for child pages
title: {
default: "Brand Name β Tagline",
template: "%s | Brand Name",
},
// Description (150-160 chars ideal)
description: "Your compelling meta description that includes primary keywords and encourages clicks.",
// Keywords (less important now, but include)
keywords: ["primary keyword", "secondary keyword", "brand name"],
// Author info
authors: [{ name: "Brand Name" }],
creator: "Brand Name",
publisher: "Brand Name",
// Robots directives
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
// OpenGraph (Facebook, LinkedIn, etc.)
openGraph: {
type: "website",
locale: "en_US",
url: BASE_URL,
siteName: "Brand Name",
title: "Brand Name β Tagline",
description: "Your compelling description for social sharing.",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Brand Name - Description",
},
],
},
// Twitter Card
twitter: {
card: "summary_large_image",
title: "Brand Name β Tagline",
description: "Your compelling description for Twitter.",
images: ["/og-image.png"],
creator: "@twitterhandle",
site: "@twitterhandle",
},
// Canonical URL
alternates: {
canonical: BASE_URL,
},
// App categorization
category: "Technology",
// Verification codes
verification: {
google: "google-site-verification-code",
// yandex: "yandex-verification-code",
// bing: "bing-verification-code",
},
};
```
Page-Level Metadata
```typescript
// app/pricing/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Pricing", // Becomes "Pricing | Brand Name" via template
description: "Simple, transparent pricing. Start free, upgrade when you need more.",
openGraph: {
title: "Pricing | Brand Name",
description: "Simple, transparent pricing. Start free, upgrade when you need more.",
},
};
export default function PricingPage() {
// ...
}
```
Dynamic Metadata (generateMetadata)
```typescript
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
type Props = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata({ params }: Props): Promise
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
return { title: "Post Not Found" };
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: "article",
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
images: [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
alternates: {
canonical: ${BASE_URL}/blog/${slug},
},
};
}
```
---
# Part 4: Authentication Middleware Integration
When using auth (Clerk, NextAuth, etc.), add SEO routes to public matchers:
Clerk (proxy.ts or middleware.ts)
```typescript
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isPublicRoute = createRouteMatcher([
"/",
"/sign-in(.*)",
"/sign-up(.*)",
"/pricing",
"/about",
"/blog(.*)",
"/terms",
"/privacy",
// SEO files - IMPORTANT!
"/robots.txt",
"/sitemap.xml",
"/sitemap(.*).xml",
// Icons
"/icon(.*)",
"/apple-icon(.*)",
"/favicon.ico",
]);
export const proxy = clerkMiddleware(async (auth, request) => {
if (!isPublicRoute(request)) {
await auth.protect();
}
});
export const config = {
matcher: [
"/((?!_next|[^?]\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).)",
"/(api|trpc)(.*)",
],
};
```
NextAuth
```typescript
export { auth as middleware } from "@/auth";
export const config = {
matcher: [
// Exclude SEO files from auth
"/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|sitemap.\\.xml).)",
],
};
```
---
# Part 5: Environment Variables
Required environment variables for SEO:
```bash
# .env.local (development)
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# .env.production (production)
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
```
---
# Quick Reference: File Locations
| File | Location | Purpose |
|------|----------|---------|
| Sitemap | app/sitemap.ts | Generates /sitemap.xml |
| Robots | app/robots.ts | Generates /robots.txt |
| Root Metadata | app/layout.tsx | Default meta tags |
| Page Metadata | app/[route]/page.tsx | Page-specific meta |
| OG Image | public/og-image.png | Social sharing image (1200x630) |
| Favicon | app/icon.tsx or public/favicon.ico | Browser tab icon |
| Apple Icon | app/apple-icon.tsx or public/apple-icon.png | iOS icon |
---
# Implementation Checklist
Before implementing, verify:
- [ ]
NEXT_PUBLIC_SITE_URLenvironment variable is set - [ ] Auth middleware allows
/robots.txtand/sitemap.xml - [ ] OG image exists at
public/og-image.png(1200x630px) - [ ] All public pages have unique titles and descriptions
- [ ] Canonical URLs point to preferred versions
After implementing, verify:
- [ ] Visit
/robots.txt- should show rules - [ ] Visit
/sitemap.xml- should show URLs - [ ] Test with [Google Rich Results Test](https://search.google.com/test/rich-results)
- [ ] Test with [Facebook Sharing Debugger](https://developers.facebook.com/tools/debug/)
- [ ] Submit sitemap to Google Search Console
More from this repository7
ux-design skill from andrehfp/tinyplate
posthog skill from andrehfp/tinyplate
stripe skill from andrehfp/tinyplate
marketing-copy skill from andrehfp/tinyplate
abacatepay skill from andrehfp/tinyplate
Generates comprehensive favicons and app icons for Next.js projects, automatically detecting app details and creating multi-size icon sets.
Automates Cloudflare domain configuration by setting up DNS records, integrating Clerk, configuring Vercel, and managing email routing.