Next.js is one of the best frameworks for SEO by default — server rendering, fast load times, and a purpose-built Metadata API. But "good by default" is not the same as "optimised." The difference between a Next.js site that ranks and one that doesn't is almost always in the details: structured data that's missing, canonical tags that conflict, render budgets that crawlers abandon, and Core Web Vitals that bleed LCP points.
This guide covers every major technical SEO lever in a Next.js App Router project, with concrete implementation for each.
1. Rendering Strategy and Crawlability
Before anything else, Google has to be able to render your pages. This is where Next.js earns its SEO reputation.
Why server rendering matters
Client-side React SPAs require Googlebot to execute JavaScript before it can see any content. While Googlebot does render JavaScript, it does so in a second wave — often days after the initial crawl. Server-rendered pages deliver fully formed HTML on the first request, which crawlers can read instantly.
Next.js App Router server components render on the server by default. That means your content exists in the HTML response before JavaScript runs in the browser.
// This runs on the server — HTML arrives fully rendered
export default async function BlogPostPage({ params }) {
const post = await getPost(params.slug);
return <article>{post.content}</article>;
}
Choosing the right strategy per page
| Page type | Strategy | Why |
|---|---|---|
| Marketing pages | Static (generateStaticParams) |
Served from CDN, fastest TTFB |
| Blog posts | Static + ISR | Pre-built, revalidates when content changes |
| Product/category pages | ISR | Fresh without per-request server cost |
| User dashboards | Client-side | Behind auth, no crawl value |
| Search results | SSR | Dynamic, but needs to be crawlable |
ISR (Incremental Static Regeneration) is the most SEO-friendly choice for content that changes but doesn't change on every request:
export const revalidate = 3600; // revalidate every hour
export default async function CategoryPage({ params }) {
const posts = await getPostsByCategory(params.category);
return <PostGrid posts={posts} />;
}
2. The Metadata API
The App Router's Metadata API is the correct way to manage <title>, <meta>, Open Graph, and Twitter tags. Never hardcode these in <head> — use the API so Next.js can deduplicate and merge metadata correctly.
Static metadata
// src/app/about/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About Us — Skybin® Technology',
description: 'Full-stack software development agency based in Noida, India. Founded 2016.',
alternates: {
canonical: 'https://www.skybin.co/about',
},
openGraph: {
title: 'About Us — Skybin® Technology',
description: 'Full-stack software development agency based in Noida, India.',
url: 'https://www.skybin.co/about',
siteName: 'Skybin® Technology',
images: [{ url: '/img/og.png', width: 1200, height: 630 }],
type: 'website',
locale: 'en_IN',
},
twitter: {
card: 'summary_large_image',
title: 'About Us — Skybin® Technology',
description: 'Full-stack software development agency based in Noida, India.',
images: ['/img/og.png'],
},
};
Dynamic metadata for data-driven pages
// src/app/blog/[category]/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.category, params.slug);
if (!post) return {};
return {
title: `${post.title} — Skybin® Technology Blog`,
description: post.excerpt,
alternates: {
canonical: `https://www.skybin.co/blog/${params.category}/${params.slug}`,
},
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.date,
authors: [post.author],
images: post.coverImage
? [{ url: post.coverImage, width: 1200, height: 630 }]
: [{ url: '/img/og.png', width: 1200, height: 630 }],
},
};
}
Title templates
Use title.template in the root layout to avoid repeating the site name in every page's metadata:
// src/app/layout.tsx
export const metadata: Metadata = {
title: {
template: '%s — Skybin® Technology',
default: 'Skybin® Technology — Add Wings To Your Ideas',
},
};
// Then per-page:
export const metadata: Metadata = {
title: 'React JS Development', // renders as "React JS Development — Skybin® Technology"
};
3. Structured Data (JSON-LD)
Structured data is how you communicate page semantics to search engines in a machine-readable format. It powers rich results — FAQs, breadcrumbs, review stars, article dates — in search listings.
Injecting JSON-LD in the App Router
The correct pattern is a <script> tag in your page or layout component:
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
datePublished: post.date,
dateModified: post.updatedAt ?? post.date,
author: {
'@type': 'Person',
name: post.author,
},
publisher: {
'@type': 'Organization',
name: 'Skybin® Technology Private Limited',
url: 'https://www.skybin.co',
logo: {
'@type': 'ImageObject',
url: 'https://www.skybin.co/img/logo.png',
},
},
image: post.coverImage,
url: `https://www.skybin.co/blog/${post.category}/${post.slug}`,
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>...</article>
</>
);
Schema types worth implementing
Organization — goes in the root layout once, establishes your entity:
{
"@type": "Organization",
"name": "Skybin® Technology Private Limited",
"url": "https://www.skybin.co",
"logo": { "@type": "ImageObject", "url": "https://www.skybin.co/img/logo.png" },
"foundingDate": "2016",
"address": {
"@type": "PostalAddress",
"addressLocality": "Noida",
"addressRegion": "Uttar Pradesh",
"addressCountry": "IN"
}
}
BreadcrumbList — on every page with breadcrumb navigation:
const breadcrumbLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://www.skybin.co' },
{ '@type': 'ListItem', position: 2, name: 'Services', item: 'https://www.skybin.co/services' },
{ '@type': 'ListItem', position: 3, name: 'React JS Development' },
],
};
FAQPage — significantly increases real estate in search results:
const faqLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map(({ question, answer }) => ({
'@type': 'Question',
name: question,
acceptedAnswer: { '@type': 'Answer', text: answer },
})),
};
Validate all structured data with Google's Rich Results Test before deploying.
4. Canonical URLs
Canonical tags tell Google which URL is the authoritative version of a page. They prevent duplicate content issues caused by query strings, trailing slashes, HTTP vs HTTPS, or www vs non-www variations.
export const metadata: Metadata = {
alternates: {
canonical: 'https://www.skybin.co/services/react-development',
},
};
Rules to follow:
- Always use the
wwwversion consistently (or non-www — pick one and stick to it, and setmetadataBaseaccordingly) - Canonical should be an absolute URL, not relative
- Paginated pages (
/blog?page=2) should canonicalise to themselves, not page 1 - Set
metadataBasein the root layout so Next.js can resolve relative URLs correctly:
// src/app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL('https://www.skybin.co'),
};
5. Sitemaps
A sitemap tells search engines which URLs exist on your site and when they were last updated. In Next.js App Router you generate it programmatically:
// src/app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/blog';
import { servicesData } from '@/lib/services-data';
export default function sitemap(): MetadataRoute.Sitemap {
const siteUrl = 'https://www.skybin.co';
const serviceUrls = servicesData.map((s) => ({
url: `${siteUrl}/services/${s.slug}`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.8,
}));
const posts = getAllPosts();
const postUrls = posts.map((p) => ({
url: `${siteUrl}/blog/${p.category}/${p.slug}`,
lastModified: new Date(p.date),
changeFrequency: 'never' as const,
priority: 0.6,
}));
return [
{ url: siteUrl, lastModified: new Date(), changeFrequency: 'weekly', priority: 1.0 },
{ url: `${siteUrl}/services`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.9 },
{ url: `${siteUrl}/blog`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.7 },
...serviceUrls,
...postUrls,
];
}
This generates /sitemap.xml automatically at build time. Submit the URL to Google Search Console.
6. robots.txt
// src/app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/dashboard/', '/_next/'],
},
],
sitemap: 'https://www.skybin.co/sitemap.xml',
};
}
Key things to block: API routes, authenticated dashboards, staging environments (via environment variable check), and /_next/ static assets that crawlers don't need.
7. Core Web Vitals
Core Web Vitals are ranking signals. The three metrics are LCP (Largest Contentful Paint), INP (Interaction to Next Paint), and CLS (Cumulative Layout Shift).
LCP — Largest Contentful Paint
LCP is typically your hero image or heading. Target: under 2.5 seconds.
Priority-load above-the-fold images:
import Image from 'next/image';
// priority disables lazy loading and adds <link rel="preload">
<Image
src="/img/hero.png"
alt="Hero"
width={1200}
height={600}
priority
sizes="100vw"
/>
Never use priority on images that are below the fold — it wastes bandwidth on things the user hasn't scrolled to.
Preload custom fonts:
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
weight: ['400', '600', '700'],
display: 'swap', // prevents invisible text during font load
});
next/font automatically inlines the font @font-face declaration and adds the correct preload link, eliminating render-blocking font requests.
CLS — Cumulative Layout Shift
CLS measures unexpected layout shifts. Common causes:
Images without dimensions: Always provide width and height on <Image> or use fill with a sized container. This lets the browser reserve space before the image loads.
// Bad — no reserved space, will shift when loaded
<img src="/hero.png" alt="Hero" />
// Good — browser reserves exact dimensions
<Image src="/hero.png" alt="Hero" width={1200} height={600} />
Dynamic content injected above existing content: Reserve space for ads, banners, or cookie notices with a fixed min-height container.
INP — Interaction to Next Paint
INP replaced FID in 2024. It measures responsiveness to user interactions. Keep JavaScript bundles lean:
// next.config.js — analyse your bundle
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({});
ANALYZE=true npm run build
Large dependencies that only run on interaction (date pickers, rich text editors, chart libraries) should be dynamically imported:
import dynamic from 'next/dynamic';
const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), {
loading: () => <div className="h-64 bg-gray-100 animate-pulse rounded" />,
ssr: false,
});
8. Open Graph Images
Open Graph images (og:image) control what appears when your page is shared on social media, Slack, or messaging apps. Next.js can generate them programmatically:
// src/app/blog/[category]/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
import { getPost } from '@/lib/blog';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function OgImage({ params }) {
const post = await getPost(params.category, params.slug);
return new ImageResponse(
(
<div
style={{
background: '#0ea5e9',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
padding: '64px',
}}
>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: 24, marginBottom: 16 }}>
Skybin® Technology Blog
</p>
<h1 style={{ color: 'white', fontSize: 56, fontWeight: 700, lineHeight: 1.2, margin: 0 }}>
{post.title}
</h1>
</div>
),
size
);
}
This generates a unique, on-brand OG image for every blog post automatically — no design tool required.
9. Internationalisation Considerations
If your site targets multiple locales, use hreflang tags to signal language/region variants to Google. With the App Router:
export const metadata: Metadata = {
alternates: {
canonical: 'https://www.skybin.co/services/react-development',
languages: {
'en-IN': 'https://www.skybin.co/services/react-development',
'en-US': 'https://www.skybin.com/services/react-development',
},
},
};
10. Checklist Before You Deploy
Run through this before every significant launch:
-
metadataBaseset in root layout - Every page has a unique
titleanddescription - All pages have explicit
canonicalURLs - JSON-LD schema validated in Rich Results Test
-
sitemap.xmlgenerated and submitted to Search Console -
robots.txtblocks/api/, dashboards, and staging - Hero images use
priorityprop - All
<Image>components havewidth,height, andsizes - Fonts loaded via
next/fontwithdisplay: swap - Core Web Vitals checked in PageSpeed Insights (aim for green on all three)
- No duplicate H1s per page
- Internal links use descriptive anchor text (not "click here")
Where This Fits in a Broader SEO Strategy
Technical SEO is the floor, not the ceiling. Getting all of this right means Google can crawl, render, and understand your site efficiently. But rankings also depend on content relevance, backlink authority, and user engagement signals — which no amount of technical configuration can substitute for.
Fix the technical foundation first, then invest in content. The two work together: good content on a technically broken site underperforms because crawlers can't index it properly, and a technically perfect site with thin content has nothing worth ranking.
If you're building a Next.js product and want a technical SEO audit as part of the engagement, get in touch — it's part of how we set up every project we ship.