One major reason companies use Next.js in production is the built-in SEO features due to server-side rendering that allows all dynamic pages to be prebuilt, thus making them crawlable.
Search Engine Optimization (SEO) can make or break your website’s online presence. With Next.js 15’s powerful new features, creating an SEO-optimized website has never been easier and has solved many issues. React faces, if you know what you’re doing.
In this guide, I will walk you through how I solved SEO optimization for this blog to ensure that each blog page has its own metadata. We’ll cover everything from basic metadata to advanced structured data, and I’ll show you exactly what code to write and why.
Understanding Next.js 15 SEO Foundations
The New Metadata API
Next.js 15 introduced a revolutionary way to handle SEO through the Metadata API. Gone are the days of manually managing <head> tags; now everything is handled through simple JavaScript objects.
Here’s the basic structure:
// app/layout.tsx
import { Metadata } from "next";
export const metadata: Metadata = {
title: {
default: "Your Business Name | What You Do",
template: "%s | Your Business Name", // Creates "Page Name | Your Business Name"
},
description: "Your compelling business description with keywords",
keywords: ["keyword1", "keyword2", "keyword3"],
};
Why This Matters
The Metadata API isn’t just syntactic sugar; it provides several crucial advantages:
- Type Safety: TypeScript ensures you never miss required fields
- Automatic Optimization: Next.js handles meta tag placement and deduplication
- Dynamic Generation: Perfect for blogs, e-commerce, and content sites
- Template System: Consistent branding across all pages
Building a Bulletproof Root Layout
Your root layout (app/layout.tsx) is the foundation of your entire SEO strategy. Here's how to structure it properly:
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://yourdomain.com/"),
title: {
default: "Your Business | Professional Service Description",
template: "%s | Your Business Name",
},
description: "Compelling description with location and services. Include your main keywords naturally while staying under 160 characters.",
keywords: [
"primary service + location",
"secondary service + location",
"industry keywords",
"local keywords",
],
authors: [{ name: "Your Business Name" }],
creator: "Your Business Name",
publisher: "Your Business Name",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
openGraph: {
type: "website",
locale: "en_US", // or "es_ES" for Spanish
url: "https://yourdomain.com",
title: "Your Business - Professional Service",
description: "Engaging description for social media sharing",
images: [
{
url: "/hero-image.jpg",
width: 1200,
height: 630,
alt: "Descriptive alt text for your main image",
},
],
siteName: "Your Business Name",
},
twitter: {
card: 'summary_large_image',
title: 'Your Business - Professional Service',
description: 'Engaging description for Twitter sharing',
images: ['/hero-image.jpg'],
},
alternates: {
canonical: "https://yourdomain.com",
},
other: {
'theme-color': '#your-brand-color',
'msapplication-TileColor': '#your-brand-color',
},
};
The Critical Parts Explained
- metadataBase: Essential for relative URLs to work properly
- title. template: Automatically adds your brand to every page
- robots configuration: Fine-tuned control over search engine behavior
- OpenGraph + Twitter: Optimized social media sharing
- canonical: Prevents duplicate content issues
Structured Data: Speaking Google’s Language
Structured data is how you tell search engines exactly what your content means. It’s the difference between Google guessing what your business does and Google knowing what your business does.
Organization Schema
Every business website needs this basic schema:
// Add this to your root layout's <head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "Organization",
"@id": "https://yourdomain.com/#organization",
"name": "Your Business Name",
"url": "https://yourdomain.com",
"logo": {
"@type": "ImageObject",
"url": "https://yourdomain.com/logo.png",
"width": 800,
"height": 600
},
"description": "What your business does and specializes in",
"foundingDate": "2020", // Your founding year
"numberOfEmployees": {
"@type": "QuantitativeValue",
"value": "10-50" // Adjust to your size
},
"address": {
"@type": "PostalAddress",
"addressLocality": "Your City",
"addressRegion": "Your State",
"addressCountry": "US"
},
"areaServed": [
{
"@type": "Place",
"name": "Your Primary Market"
},
{
"@type": "Place",
"name": "Your Secondary Market"
}
],
"serviceType": [
"Service 1",
"Service 2",
"Service 3"
]
})
}}
/>
Website Schema
This tells search engines about your site structure:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
"@id": "https://yourdomain.com/#website",
"url": "https://yourdomain.com",
"name": "Your Business Name",
"description": "Brief description of your website",
"publisher": {
"@id": "https://yourdomain.com/#organization"
},
"inLanguage": "en-US"
})
}}
/>
Dyamic SEO Metadata
One of the challenges you will face is now generating the full metadata for each dynamic route in Next.js. Dynamic routes such as /blog/[id] are used to generate static pages for every blog during build to make the site load faster for the user.
Since each blog has a custom title and description, you cannot rely on the initial metadata in the layout folder, as this references the whole site. To solve this problem, first create a reusable metadata object. Here is a sample reusable metadata object:
import { Metadata } from "next";
export const metaobject: Metadata = {
title: "Code Tutorials, AI Guides & Dev Tips - Tech Tales",
description:
"Tech Tales is a ........",
// Basic metadata
applicationName: "Tech Tales",
authors: [{ name: "*********", url: "https://techtales.vercel.app" }],
generator: "Next.js",
keywords: [
"next.js",
"react",
"javascript",
"typescript",
"artificial intelligence",
"blog",
"technology",
"coding tutorials",
"programming blog",
"developer tips",
],
creator: "*********",
publisher: "Tech Tales",
// Open Graph metadata
openGraph: {
title: "Code Tutorials, AI Guides & Dev Tips - Tech Tales",
description:
"Tech Tales is a ..........",
url: "https://techtales.vercel.app",
siteName: "Tech Tales",
images: [
{
// replace this with url to og-image
url: "https://techtales.vercel.app/og-image.png",
width: 1200,
height: 630,
alt: "Tech Tales",
},
],
locale: "en_US",
type: "website",
},
// Twitter metadata
twitter: {
card: "summary_large_image",
title: "Tech Tales | Code Tutorials, AI Guides & Dev Tips",
description:
"Tech Tales is a ........",
creator: "@*********",
images: ["https://techtales.vercel.app/logo.png"],
},
// Verification for search engines
verification: {
google: "***********",
},
// App-specific metadata
appleWebApp: {
capable: true,
title: "Tech Tales",
statusBarStyle: "black-translucent",
},
// Robots directives
robots: {
index: true,
follow: true,
nocache: true,
googleBot: {
index: true,
follow: true,
noimageindex: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
manifest: "/webmanifest.json",
// Format detection
formatDetection: {
email: false,
address: false,
telephone: false,
},
};
For dynamic pages, we can use this metadata object as the base and only change some properties like title, author, description, keywords and images. Here is a custom function that I call during build (generateStaticParams) to create metadata for all blogs:
Generate Custom Metadata
// Function to generate MetaData
export async function generateMetadata({
params,
}: {
params: Promise<{ path: string[] }>;
}) {
const { path } = await params;
const pathname = path.join("/");
const blog = (await getData(pathname)) as unknown as FullBlogData;
if (!blog) {
return {
...metaobject,
title: "Blog Post Not Found - Tech Tales",
description: "The requested blog post could not be found.",
};
}
const image = blog.image as CoverImage;
return {
...metaobject,
title: `${blog.title} - Tech Tales`,
description: blog.description ?? "This blog has not been updated yet",
keywords: blog.tags?.split(",") ?? metaobject.keywords,
creator: blog.author.username,
authors: [
{
name: blog.author.username,
url: `https://techtales.vercel.app/explore/${blog.author.handle}`,
},
],
openGraph: {
...metaobject.openGraph,
title: `${blog.title} - Tech Tales`,
description: blog.description ?? "This blog has not been updated yet",
url: `https://techtales.vercel.app/read/${blog.path}`,
images: [
{
url: image?.secure_url || "https://techtales.vercel.app/og-image.png",
width: 1200,
height: 630,
alt: blog.title ?? "Tech Tales",
},
],
},
twitter: {
...metaobject.twitter,
title: `${blog.title} - Tech Tales`,
description: blog.description ?? "This blog has not been updated yet",
images: [image.secure_url || "https://techtales.vercel.app/logo.png"],
},
} satisfies Metadata;
}
Additionally, we need to create a JSON-LD script for each blog page. The way that we can do this is by appending the script into the blog page and computing the structure of the JSON during build time.
export default async function page({
params,
}: {
params: Promise<{ path: string[] }>;
}) {
const { path } = await params;
const pathname = path.join("/");
if (!pathname) {
redirect("not-found");
}
const blog = await getData(pathname);
if (!blog) {
redirect("/410");
}
// Build JSON-LD
const jsonLd = {
"@context": "https://schema.org",
"@type": "TechArticle", // more specific than Article for tech blogs
headline: blog.title,
description: blog.description ?? "",
datePublished: blog.createdAt, // ISO 8601 — e.g. "2024-03-15T10:00:00Z"
dateModified: blog.updatedAt ?? blog.createdAt,
url: `https://techtales.vercel.app/read/${blog.path}`,
image: {
"@type": "ImageObject",
url: blog.image?.secure_url ?? "https://techtales.vercel.app/og-image.png",
width: 1200,
height: 630,
},
author: {
"@type": "Person",
name: blog.author.username,
url: `https://techtales.vercel.app/explore/${blog.author.handle}`,
},
publisher: {
"@type": "Organization",
name: "Techtales",
url: "https://techtales.vercel.app",
logo: {
"@type": "ImageObject",
url: "https://techtales.vercel.app/logo.png",
width: 60,
height: 60,
},
},
keywords: blog.tags ?? "",
articleSection: "Technology",
inLanguage: "en-US",
};
return (
<section
className="@container bg-muted/50 dark:bg-background min-h-screen"
suppressHydrationWarning>
<Script
id="blog-jsonld"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<Slug blog={blog}/>
</section>
);
}
The last thing to consider is creating robots.txt and sitemaps. I often generate this automatically using the next-sitemap package. However, Next.js 15's app/sitemap.ts approach is newer and better and thus can be used to create sitemaps that are dynamic and respond to changes in the data. Since next-sitemap only builds the sitemap during build time, it does not update when new pages are added, for example, when users submit new blog posts for publication.
SEO Checklist for Launch
- [✔] Google Search Console setup
- [✔] Google Analytics installed
- [✔] All images have descriptive alt tags
- [✔] Every page has unique title <h1> and description
- [✔] Sitemap submitted to search engines
- [✔] Site speed under 3 seconds
- [✔] Mobile-friendly design
- [✔] HTTPS enabled
- [✔] Internal linking strategy implemented
Conclusion
SEO in Next.js 15 isn’t just about adding meta tags; it’s about creating a comprehensive strategy that covers technical optimization, content structure, and user experience.