Next.js 14 SEO: The Complete Technical Guide

July 31, 2026
22 min read
By Rejish Khanal
Next.jsTechnical SEOApp RouterCore Web VitalsNext.js 14 SEONext.js Metadata APIJSON-LDNext.js SitemapJavaScript SEO
Next.js gives you more SEO control than almost any other React framework, but that control only helps if you actually use it. As a technical SEO specialist, most Next.js sites I audit are running the default setup: no dynamic metadata, no structured data, an auto-generated sitemap nobody checked, and images that never got optimized. The framework did its job. The implementation did not. This guide covers what actually matters for SEO in Next.js 14 with the App Router, with working code for each piece. It assumes you are on the App Router (the app/ directory), since that is where Next.js has put its SEO tooling since version 13. ## 1. The Metadata API: Getting Titles and Descriptions Right Next.js 14 replaces the old next/head approach with a built-in Metadata API. You export a metadata object (or a generateMetadata function for dynamic pages) from any layout or page file, and Next.js handles rendering it into the correct tags on the server. ### Static metadata For pages where the title and description do not change, export a metadata object directly: ```tsx // app/about/page.tsx import { Metadata } from 'next'; export const metadata: Metadata = { title: 'About Rejish Khanal | Technical SEO Specialist in Kathmandu', description: 'Technical SEO specialist and full stack developer based in Kathmandu, Nepal. SEO audits, Core Web Vitals fixes, and modern web development.', }; ``` ### Dynamic metadata for dynamic routes For blog posts, product pages, or anything with a dynamic slug, use generateMetadata. This runs on the server and can fetch data before rendering the tags: ```tsx // app/blog/[slug]/page.tsx import { Metadata } from 'next'; type Props = { params: { slug: string }; }; export async function generateMetadata({ params }: Props): Promise<Metadata> { const post = await getPostBySlug(params.slug); if (!post) { return { title: 'Post Not Found' }; } return { title: `${post.title} | Rejish Khanal`, description: post.excerpt, openGraph: { title: post.title, description: post.excerpt, images: [post.coverImage], type: 'article', publishedTime: post.publishedAt, }, }; } ``` This is the single biggest upgrade most Next.js sites need. If every blog post on your site has the same title tag because nobody wrote a generateMetadata function, you are leaving rankings on the table for every post you publish. ### Title templates If you want every page title to follow a consistent pattern automatically, set a template in the root layout: ```tsx // app/layout.tsx export const metadata: Metadata = { title: { default: 'Rejish Khanal | Technical SEO Specialist', template: '%s | Rejish Khanal', }, }; ``` Now any page that just sets title: 'Next.js SEO Guide' will render as "Next.js SEO Guide | Rejish Khanal" without you writing the suffix on every page. ### metadataBase (do not skip this) Without metadataBase, any relative image URL in your Open Graph tags will not resolve correctly, and social platforms will fail to show a preview image. Set it once in the root layout: ```tsx export const metadata: Metadata = { metadataBase: new URL('https://rejishkhanal.com.np'), }; ``` ## 2. Open Graph and Twitter Cards Open Graph tags control how your page looks when shared on social media, WhatsApp, LinkedIn, or Slack. A missing or broken OG image is a common, easy-to-miss reason a page gets fewer shares and backlinks than it should. ```tsx export const metadata: Metadata = { openGraph: { title: 'Next.js 14 SEO: The Complete Technical Guide', description: 'A complete technical guide to Next.js 14 SEO.', url: 'https://rejishkhanal.com.np/blog/nextjs-14-seo-complete-technical-guide', siteName: 'Rejish Khanal', images: [{ url: '/og-images/nextjs-seo-guide.png', width: 1200, height: 630 }], locale: 'en_US', type: 'article', }, twitter: { card: 'summary_large_image', title: 'Next.js 14 SEO: The Complete Technical Guide', description: 'A complete technical guide to Next.js 14 SEO.', images: ['/og-images/nextjs-seo-guide.png'], }, }; ``` ### Generating OG images dynamically Next.js includes ImageResponse, which lets you generate a unique OG image per page at request time using JSX and CSS, instead of designing one static image for every post. Note that this file runs on the Edge Runtime by default, which is incredibly fast. ```tsx // app/blog/[slug]/opengraph-image.tsx import { ImageResponse } from 'next/og'; export const size = { width: 1200, height: 630 }; export const contentType = 'image/png'; export default async function Image({ params }: { params: { slug: string } }) { const post = await getPostBySlug(params.slug); return new ImageResponse( ( <div style={{ display: 'flex', flexDirection: 'column', width: '100%', height: '100%', padding: 80, background: '#0f172a', color: 'white' }}> <div style={{ fontSize: 56, fontWeight: 700 }}>{post.title}</div> </div> ), { ...size } ); } ``` Drop this file next to any page.tsx and Next.js automatically wires it up as that route's OG image. No manual meta tag needed. ## 3. Canonical URLs Duplicate content, whether from URL parameters, trailing slashes, or the same content reachable at two paths, splits your ranking signals across multiple URLs instead of consolidating them into one. Set canonical tags explicitly rather than hoping Google guesses correctly: ```tsx export const metadata: Metadata = { alternates: { canonical: 'https://rejishkhanal.com.np/blog/nextjs-14-seo-complete-technical-guide', }, }; ``` For dynamic routes, build this from the same slug used in generateMetadata so it always points to the correct canonical version of that page. Remember, if you skip metadataBase, your canonical URLs might also break if you try to use relative paths. ## 4. robots.txt and Sitemaps as Code Next.js 14 lets you define both robots.txt and your sitemap as TypeScript files instead of static files, which means they can be generated dynamically from your actual content. ### robots.ts ```tsx // app/robots.ts import { MetadataRoute } from 'next'; export default function robots(): MetadataRoute.Robots { return { rules: { userAgent: '*', allow: '/', disallow: ['/api/', '/admin/'], }, sitemap: 'https://rejishkhanal.com.np/sitemap.xml', }; } ``` ### sitemap.ts ```tsx // app/sitemap.ts import { MetadataRoute } from 'next'; export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const posts = await getAllPosts(); const postEntries: MetadataRoute.Sitemap = posts.map((post) => ({ url: `https://rejishkhanal.com.np/blog/${post.slug}`, lastModified: post.updatedAt, changeFrequency: 'monthly', priority: 0.7, })); return [ { url: 'https://rejishkhanal.com.np', lastModified: new Date(), priority: 1 }, { url: 'https://rejishkhanal.com.np/blog', lastModified: new Date(), priority: 0.8 }, ...postEntries, ]; } ``` Because this pulls from getAllPosts(), every new blog post you publish is automatically added to the sitemap on the next build or revalidation. No manual sitemap editing, and no forgetting to add a new page. If your site scales past 50,000 URLs, you can use the sitemap index pattern to split them into multiple sitemap.ts files. ## 5. Structured Data (JSON-LD) Structured data does not directly boost rankings, but it makes your content eligible for rich results (star ratings, FAQ dropdowns, article cards) and gives search engines and AI answer engines a much clearer signal about what your content actually is. This matters more now that AI search tools (AEO/GEO) lean heavily on structured, unambiguous data to decide what to cite. Render JSON-LD as a script tag directly in the page component: ```tsx // app/blog/[slug]/page.tsx export default async function BlogPost({ params }: Props) { const post = await getPostBySlug(params.slug); const jsonLd = { '@context': 'https://schema.org', '@type': 'Article', headline: post.title, description: post.excerpt, image: post.coverImage, datePublished: post.publishedAt, dateModified: post.updatedAt, author: { '@type': 'Person', name: 'Rejish Khanal', url: 'https://rejishkhanal.com.np', }, }; return ( <> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> <article>{/* post content */}</article> </> ); } ``` Useful schema types beyond Article: Organization on your homepage, BreadcrumbList on any page nested more than one level deep, FAQPage on any page with a question and answer section, and LocalBusiness if you serve a specific city or region. ## 6. Rendering Strategy: Picking the Right One for SEO Next.js gives you three main ways to render a page, and picking the wrong one for the wrong content type is a common, invisible SEO mistake. **Static Site Generation (default in the App Router)** renders the page to HTML at build time. This is the fastest option and the best default for SEO, since the full HTML is ready before any crawler or user requests it. Use this for blog posts, service pages, and anything that does not change per request. **Incremental Static Regeneration (ISR)** works like static generation but re-generates the page in the background after a set interval, so content can update without a full rebuild: ```tsx export const revalidate = 3600; // regenerate this page at most once per hour ``` Use this for pages backed by a CMS or database where content changes occasionally but not on every request, like a blog post that might get edited. **Server-Side Rendering** renders the page fresh on every request. It is necessary for genuinely per-request content (a logged-in dashboard, real-time pricing), but it is the slowest option for SEO purposes because there is no cached HTML ready to serve instantly. Do not default to this for content pages just because it feels more dynamic. **Streaming and Suspense:** Next.js 14 heavily utilizes React Suspense. Wrapping heavy components in a Suspense boundary allows the static HTML shell (including your metadata and critical CSS) to be sent to the browser immediately, while the heavy component loads asynchronously. This drastically improves Time to First Byte (TTFB) and perceived load time. If you are not sure which one a page is using, that alone is worth checking. A blog page accidentally rendering with SSR when it could be static is a quiet performance tax on every single pageview. ## 7. Core Web Vitals in Next.js Next.js has built-in tools for the three metrics Google measures directly, and most Core Web Vitals problems on Next.js sites come from not using them. **LCP (Largest Contentful Paint):** Use next/image for every image, not a plain <img> tag. It handles responsive sizing, lazy loading below the fold, and modern formats like WebP/AVIF automatically: ```tsx import Image from 'next/image'; <Image src="/hero.jpg" alt="Description" width={1200} height={630} priority /> ``` The priority prop matters specifically for LCP: set it on whatever image appears above the fold (usually your hero image), so Next.js loads it immediately instead of lazily. **INP (Interaction to Next Paint):** Heavy client-side JavaScript is the usual culprit. Use next/dynamic to lazy-load components that are not needed immediately, especially anything below the fold or behind an interaction like a modal: ```tsx import dynamic from 'next/dynamic'; const HeavyChart = dynamic(() => import('@/components/HeavyChart'), { ssr: false }); ``` **CLS (Cumulative Layout Shift):** Almost always caused by images or ads without reserved space, or web fonts loading in and shifting text. next/image requires width and height, which prevents image-caused shift automatically. For fonts, use next/font, which self-hosts and preloads fonts to avoid the flash of unstyled text that causes layout jumps: ```tsx import { Inter } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], display: 'swap' }); ``` ## 8. Handling Pagination and Filtered Pages Category pages, tag pages, and filtered product listings can quietly create thousands of low-value, near-duplicate URLs that dilute your site's crawl budget and ranking signals. For any page generated by a filter or query parameter that does not need to be indexed on its own, set: ```tsx export const metadata: Metadata = { robots: { index: false, follow: true }, }; ``` This tells Google not to index the page but still follow its links, which is usually the right balance for filtered or paginated views. Google no longer requires rel="prev/next" tags, so controlling indexation via the robots meta tag is the modern approach. ## A Working Checklist - [ ] Every dynamic route uses generateMetadata, not a static title - [ ] metadataBase is set in the root layout - [ ] Every page has a canonical tag pointing to itself (or to the preferred version, for duplicates) - [ ] sitemap.ts pulls from live content, not a hardcoded list - [ ] robots.ts disallows admin and API routes - [ ] Article, Organization, and Breadcrumb JSON-LD are present where relevant - [ ] All images use next/image, hero images use priority - [ ] Fonts load through next/font - [ ] Heavy, non-critical components are lazy-loaded with next/dynamic - [ ] Filtered or paginated URLs are set to noindex, follow - [ ] Content pages use static generation or ISR, not SSR by default ## When to Bring in Help Everything above is something a developer on your team can implement directly. Where it gets harder is diagnosing which of these your specific site is missing, since most of these problems are invisible until you check the actual rendered HTML and Search Console data together. If you want a second set of eyes on your Next.js implementation specifically, that is exactly what a technical SEO audit covers. I go through the metadata, structured data, sitemap, and rendering strategy on your actual site and hand you a prioritized list of what to fix first. Whether you are building a new Next.js application or migrating an older site to the App Router, aligning your technical implementation with search engine requirements from day one saves months of troubleshooting later.