Most Next.js SEO guides stop at meta tags. That's table stakes. Here's what actually moves rankings.
1. Dynamic Sitemap
A static sitemap.xml misses dynamically generated pages. Generate it at build time from your data.
// app/sitemap.ts
import { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = "https://yourdomain.com";
// Static pages
const staticPages = [
{ url: baseUrl, priority: 1.0 },
{ url: `${baseUrl}/tools`, priority: 0.9 },
{ url: `${baseUrl}/blog`, priority: 0.8 },
];
// Dynamic tool pages
const tools = await getToolsFromRegistry();
const toolPages = tools.map(tool => ({
url: `${baseUrl}/tools/${tool.slug}`,
lastModified: new Date(),
priority: 0.85,
}));
// Dynamic blog posts
const posts = await getBlogPosts();
const blogPages = posts.map(post => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
priority: 0.7,
}));
return [...staticPages, ...toolPages, ...blogPages];
}
2. JSON-LD Structured Data
Meta tags tell search engines what a page is about. Structured data tells them the type of content, enabling rich results (FAQ dropdowns, breadcrumbs, how-to steps in search results).
// For a FAQ page or tool page with FAQs
function FAQStructuredData({ faqs }: { faqs: Array<{q: string, a: string}> }) {
const schema = {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": faqs.map(faq => ({
"@type": "Question",
"name": faq.q,
"acceptedAnswer": {
"@type": "Answer",
"text": faq.a
}
}))
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
// For a WebApplication (tool page)
const toolSchema = {
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Image Compressor",
"applicationCategory": "UtilityApplication",
"operatingSystem": "Web Browser",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"description": "Compress images in your browser. No upload required."
};
// For a blog post
const articleSchema = {
"@context": "https://schema.org",
"@type": "Article",
"headline": post.title,
"author": {
"@type": "Person",
"name": "Author Name"
},
"datePublished": post.publishedAt,
"dateModified": post.updatedAt,
};
3. Metadata API (Next.js 13+)
// app/tools/[slug]/page.tsx
import { Metadata } from "next";
export async function generateMetadata({
params
}: {
params: { slug: string }
}): Promise<Metadata> {
const tool = await getTool(params.slug);
return {
title: `${tool.name} — Free Online Tool`,
description: tool.description,
keywords: tool.keywords,
openGraph: {
title: tool.name,
description: tool.description,
type: "website",
url: `https://yourdomain.com/tools/${tool.slug}`,
images: [{
url: `https://yourdomain.com/og/${tool.slug}.png`,
width: 1200,
height: 630,
}],
},
twitter: {
card: "summary_large_image",
title: tool.name,
description: tool.description,
},
alternates: {
canonical: `https://yourdomain.com/tools/${tool.slug}`,
},
};
}
4. Open Graph Images
Auto-generated OG images dramatically improve click-through rates from social sharing.
// app/og/[slug]/route.tsx
import { ImageResponse } from "next/og";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const title = searchParams.get("title") || "Tool";
return new ImageResponse(
(
<div style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
background: "#0f172a",
color: "#ffffff",
fontFamily: "sans-serif",
}}>
<div style={{ fontSize: 64, fontWeight: 700 }}>{title}</div>
<div style={{ fontSize: 28, color: "#94a3b8", marginTop: 16 }}>
Free · No Upload · Browser-Based
</div>
</div>
),
{ width: 1200, height: 630 }
);
}
5. robots.txt
// app/robots.ts
import { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/admin/", "/api/"],
},
sitemap: "https://yourdomain.com/sitemap.xml",
};
}
6. Core Web Vitals
Google uses CWV as a ranking signal. Key issues in Next.js apps:
LCP (Largest Contentful Paint):
// Prioritize hero images
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // Preloads — use only for above-fold images
/>
CLS (Cumulative Layout Shift):
Always specify image dimensions. Dynamic content should have reserved space.
// Bad — causes CLS when image loads
<img src="/image.jpg" />
// Good — dimensions reserved
<Image src="/image.jpg" width={800} height={400} alt="..." />
INP (Interaction to Next Paint):
Heavy client-side processing blocks the main thread. Use Web Workers for intensive operations.
7. Hreflang for Multilingual Sites
If you serve content in multiple languages:
export async function generateMetadata(): Promise<Metadata> {
return {
alternates: {
languages: {
"en": "https://yourdomain.com/en/tools/image-compress",
"ko": "https://yourdomain.com/ko/tools/image-compress",
},
},
};
}
8. Canonical URLs
Prevent duplicate content penalties from query parameters:
// Always set canonical explicitly
alternates: {
canonical: `https://yourdomain.com/tools/${slug}`,
// Without this, ?ref=twitter etc. create duplicate pages
}
What Actually Made a Difference
After running a tool site with 79 indexed pages:
- JSON-LD FAQ markup — Showed up as FAQ rich results in Google within 2 weeks of submission
- Dynamic sitemap — Tool pages got indexed 3x faster after submitting
- Canonical URLs — Eliminated duplicate indexing issues from URL parameters
- Page titles with keywords — "Image Compressor Free Online" outperforms "Image Compressor"
Meta description doesn't affect ranking directly but affects click-through rate. Write for humans, not bots.
These techniques are in production at ToolZip — 79 indexed pages across tools and blog content.
Top comments (0)