What Next.js Gives You for Free
Next.js Pages Router generates HTML by default, either statically at build time or on the server depending on how the page is configured. For both, crawlers receive rendered HTML rather than having to execute JavaScript to see the content — a meaningful advantage over a traditional client-rendered React SPA that you get without any SEO-specific configuration. Some content may still hydrate client-side after the initial HTML loads, but the core page content is in the document from the start.
What you don't get for free: meta tags, structured data, or any of the signals that tell Google and social platforms how to represent your pages. That part is on you.
One note before the details: this site uses the Pages Router, so everything below is next/head and manual files. If you're on the App Router, the modern equivalents are the metadata export (or generateMetadata) for meta tags and app/sitemap.ts for a generated sitemap — same concepts, less manual wiring.
Meta Tags and the key Prop Trick
Every page in a Next.js Pages Router app can include a <Head> block from next/head. I set a site-wide <title> and description in Layout.js (the root shell that wraps every page) and then override them per-page using the key prop.
The key prop is the part that surprised me. Without it, Next.js merges all <Head> tags from every component in the render tree, so you can end up with two og:title tags, one from the layout and one from the page. How crawlers handle duplicates varies by platform (Facebook, LinkedIn, Slack, and X each parse a little differently), so the result is unpredictable. Adding key="og:title" to both tags tells Next.js to treat them as the same slot: only one tag ends up in the rendered head, and the page-level one wins.
This applies to every duplicated tag, including the plain meta description — if both your layout and your page set <meta name="description">, they need key="description" too. In my setup I sidestep that by not setting a plain description in Layout.js at all; each page defines its own, so there's only ever one tag to begin with.
Open Graph — What Controls the Link Preview
Open Graph tags control the card preview when someone shares a link on LinkedIn, Slack, or iMessage. Without them, platforms fall back to scraping whatever text they find first, usually the wrong thing.
The full set I use on every blog post:
og:type—articlefor posts,websitefor the homepageog:title— the page title, matching the<title>tagog:description— a standalone sentence written for the card, not a truncated version of the meta descriptionog:url— the canonical URL of the page, including the full domain; this should match thehrefof your<link rel="canonical">tag exactlyog:image— an absolute URL to the banner image; I use the blog post banner at a 2:1 ratio (this one is 1274×640px)og:image:widthandog:image:height— can help crawlers process the image more efficiently and avoid extra inspection requests, though some platforms fetch the image regardlessog:image:alt— a text description of the image for screen-reader users on sharing platforms
Alongside these tags, add a <link rel="canonical" href="..." /> tag pointing to the same URL as og:url. The canonical tag tells Google which URL is the definitive version of the page. Skip it and Google may index multiple variants as separate pages — with and without a trailing slash, or with query parameters appended by analytics tools. One thing worth knowing: Google treats canonicals as strong hints rather than directives. If other signals conflict (for example, a different URL accumulating more inbound links), Google may select a different canonical than the one you specified.
Use absolute URLs for the image. A relative path like /images/blogs/banner.png is risky: many social crawlers expect a full URL, and some platforms won't reliably resolve relative paths. I store the site URL in a SITE_URL constant at the top of each post file and template the image path from it.
Twitter Cards
X (formerly Twitter) has its own meta tags. Its crawler does fall back to Open Graph when they're missing, but the Twitter-specific tags take priority and give you explicit control over how the card renders. The core set is: twitter:card, twitter:title, twitter:description, twitter:image, and twitter:image:alt. I set twitter:card to summary_large_image in Layout.js so every page gets a large image card by default. twitter:url is sometimes included but isn't part of X's core card requirements and isn't widely used by the platform — treat it as optional.
Same story with the key prop: every Twitter meta tag gets one so page-level values override the layout defaults. Yes, it means duplicating content between the OG tags and the Twitter tags. You could rely on the OG fallback and skip the Twitter set entirely; I keep both because explicit beats implicit when debugging a broken card preview.
JSON-LD Structured Data
Meta tags help social platforms. JSON-LD structured data helps Google understand what a page is about and enables rich results in search. For this portfolio, I use two schemas on every blog post.
BlogPosting
The BlogPosting schema tells Google the post headline, description, author, publisher, publish date, and banner image. It makes the post eligible to show article metadata in search (author name, date, sometimes the image) instead of just a plain blue link, though Google doesn't guarantee it'll display any of them.
FAQPage
If the post has a visible FAQ section (every post on this site does), a matching FAQPage JSON-LD block marks those Q&As up as structured data. One honest caveat: Google significantly reduced FAQ rich result eligibility in 2023, and most sites outside government and authoritative health domains rarely receive FAQ rich snippets today — so a portfolio blog shouldn't expect them to show up. I keep the markup anyway because it's valid structured data that helps Google understand the page content, and it costs nothing to maintain alongside the visible FAQ.
The rule that matters: the schema must match the visible content exactly. Google cross-references the JSON-LD against what's actually on the page. If the visible question is “How do I configure X?” and the schema says “Configuring X,” that mismatch can get the rich snippet flagged or removed. I copy the FAQ text from the JSX, strip the markup, and paste it into the schema.
Both schemas go in <Head> as <script type="application/ld+json"> tags rendered with dangerouslySetInnerHTML={{ __html: jsonLdString }}. JSON-LD must be inserted as raw text inside the <script> tag. dangerouslySetInnerHTML ensures the JSON is emitted exactly as written, without React escaping any characters — which is why it's the standard approach for JSON-LD.
The Sitemap
Next.js doesn't generate a sitemap automatically in the Pages Router. I maintain public/sitemap.xml by hand, a small XML file with one <url> block per page. For this portfolio that's: homepage, projects, contact, privacy policy, blog index, and every individual post.
Each entry carries the URL, a lastmod date, plus changefreq and priority values. Worth knowing: Google has said it largely ignores changefreq and priority; the fields that actually matter are the URL and lastmod. I still fill in the other two (monthly for the homepage, yearly for posts) because they're harmless and other crawlers may read them, but don't expect them to influence how Google crawls your site.
Once the sitemap was ready, I submitted the URL through Google Search Console. On this site, new posts have often been indexed within a day or two of publishing after submission — though indexing times vary widely and aren't guaranteed. Submission still beats waiting for Google to discover them through crawling.
robots.txt
A small but necessary file. public/robots.txt tells crawlers which paths they're allowed to index. For a portfolio with no private routes, it's three lines:
User-agent: *
Allow: /
Sitemap: https://www.yanakrukovets.com/sitemap.xmlThe Sitemap: directive at the bottom tells any crawler that reads robots.txt where to find the sitemap, without needing Search Console submission.
Other Public Files Worth Adding
A few more files that belong in public/ — none are ranking factors, but they matter for mobile and PWA behavior:
favicon.ico— the tab icon; browsers request it automaticallymanifest.json— enables “Add to Home Screen” on mobile and defines the app name and icon for PWA installations
Two <Head> tags worth setting in your layout alongside the SEO tags:
<link rel="icon" href="/favicon.ico" />— explicit favicon declaration (browsers find it anyway, but this prevents a 404 log entry in some setups)<meta name="theme-color" content="#..." />— sets the browser chrome color on Android and in PWA mode; worth adding if your site has a defined color scheme
Performance and Core Web Vitals
Meta tags and structured data tell Google what your page is about. Core Web Vitals tell it how your page performs. Since 2021, performance has been an explicit ranking signal through Google's page experience update — though content relevance generally outweighs performance differences in most ranking decisions. It matters, just not more than having useful content.
The three metrics that matter:
- LCP (Largest Contentful Paint) — how long until the biggest visible element finishes rendering. For a portfolio, that's usually the hero image or the banner on a blog post.
- CLS (Cumulative Layout Shift) — how much the page visually jumps while loading. Images without explicit width/height attributes are the most common cause. So is loading a font that pushes text around before it settles.
- INP (Interaction to Next Paint) — replaced FID in 2024. Measures how quickly the page responds to user input like clicks or taps.
Next.js helps with several of these by default, often without you thinking of it as SEO work.
next/image. The Image component prevents layout shift by reserving space for the image before it loads. In most cases you provide width and height props — or use fill for images that should cover their container — and Next.js uses those values to hold the space before the image arrives. It also lazy-loads images below the fold and serves modern optimized formats like WebP or AVIF where the browser supports them. I add priority to above-the-fold images (like the blog post banner) so they preload instead of wait. That directly improves LCP on pages where the banner is the largest element.
Static generation and edge delivery. Most pages on this site are statically generated at build time. When deployed on Vercel, those pages are distributed through Vercel's global edge network, so the browser gets a complete document without waiting for a server to render anything. That's a meaningful LCP improvement over a server-rendered or client-rendered equivalent, and it requires no SEO-specific configuration — it's just how Pages Router works by default.
Fonts. next/font inlines critical font CSS at build time and eliminates the external round-trip to Google Fonts. On this site I load fonts from Google Fonts using the media="print" non-blocking trick — the stylesheet loads as a low-priority print resource and swaps to all once it's downloaded, so it never blocks the initial render. Combined with preconnect hints to fonts.googleapis.com and fonts.gstatic.com, the font request starts early without delaying the page. It's one more network request than next/font, but not a render-blocking one.
For measuring: PageSpeed Insights runs against your live URL and shows field data (from actual Chrome users) alongside lab data. LCP and CLS are where portfolio sites tend to have the clearest improvements.
Debugging Tools Worth Knowing
Once the tags are in place, these tools are how you verify they actually work:
- Facebook Sharing Debugger — scrapes your page and shows exactly which Open Graph tags Facebook reads, including the rendered preview card. Use it to force a cache refresh when you update an image.
- Twitter Card Validator — same idea for X (formerly Twitter); lets you confirm the card type and preview before sharing.
- Google Rich Results Test — validates your JSON-LD structured data and shows which rich result types the page is eligible for.
- Google Search Console — the ground truth. Shows coverage errors, structured data warnings, and confirms whether Google has actually indexed your pages. Submit your sitemap here first.
In practice I use the Rich Results Test after every new post and Search Console a few days later to confirm the page was picked up cleanly.
What It Looked Like Before vs. After
Before: sharing a link on LinkedIn produced a blank card with the domain name. Google indexed the homepage but ignored the blog. No rich snippets, no structured data warnings in Search Console because there was nothing to warn about.
After: every page produces a proper link preview with the right title, description, and banner image. Blog posts are eligible to display article metadata like publication date, author information, and images in search. The structured data validates cleanly in Search Console. The sitemap is accepted and processed. None of this required a plugin or a third-party service, just a few dozen lines of markup per page and one XML file maintained by hand.
If you're building a Next.js portfolio and haven't touched SEO yet, start with the key prop and Open Graph tags. Those two changes will make your links look professional when shared anywhere, which matters more than most developers expect.
Frequently Asked Questions
What meta tags does every Next.js page need for SEO?
At minimum: a unique title tag, a meta description, and a canonical URL. For social sharing, add the full Open Graph set (og:type, og:title, og:description, og:url, og:image) and the matching Twitter card tags. For blog posts, a BlogPosting JSON-LD block gives Google the structured data it needs for rich results.
What is the key prop in Next.js <Head> and why do you need it?
Next.js merges <Head> tags from every component in the render tree. If your layout sets og:title and your page also sets og:title, you end up with two tags, and different crawlers handle duplicates differently, so the result is unpredictable. Adding key="og:title" to both tags tells Next.js to treat them as the same slot: only one tag ends up in the rendered head, and the page-level tag always overrides the layout default.
What is JSON-LD and does it actually help SEO?
JSON-LD is a way to embed structured data in a page as a script tag. Google reads it to understand the content type (article, FAQ, product, event) and can use it for rich results in search. For a portfolio blog, two schemas matter most: BlogPosting (author, publish date, headline) and FAQPage (marks Q&A content up as structured data — though Google significantly reduced FAQ rich result eligibility in 2023, and most sites outside government and authoritative health domains rarely receive FAQ rich snippets today).
Do I need a sitemap for a small portfolio site?
Yes, even for a small site. A sitemap helps Google discover your URLs efficiently — especially useful on smaller sites where internal linking is limited. For a Next.js Pages Router project, the easiest approach is a static sitemap.xml in the public folder, maintained by hand. Submit it once through Google Search Console; on this site new pages have often been indexed within a day or two of publishing, though indexing times vary and aren't guaranteed.
Do Core Web Vitals affect SEO in a Next.js site?
Yes. Since 2021, Core Web Vitals (LCP, CLS, and INP) are explicit Google ranking signals as part of the page experience update — though content relevance generally outweighs performance differences in most ranking decisions. Next.js helps with several of them by default: next/image reserves space for images before they load to prevent layout shift (CLS), serves modern formats like WebP or AVIF where supported, and lazy-loads below-the-fold images. When deployed on Vercel, statically generated pages are distributed through Vercel's global edge network, which lowers LCP. The fastest way to check your actual scores is PageSpeed Insights — it runs against your live URL and shows both field data from real Chrome users and lab data.
