Anup Haldar / blog
ArticlesProjectsAbout
All articles
Next.jsSEOPerformance

The SEO plumbing behind this blog, and two warnings that turned out to be nothing

How this Next.js blog's sitemap, robots.txt, llms.txt and JSON-LD are wired up, plus two Search Console and PageSpeed warnings that weren't bugs.

By Anup Haldar16 September 202610 min read

Publishing the Claude Code guide meant this site suddenly had something worth ranking for. Before writing a second word, I wanted the plumbing underneath it to be right — one sitemap, one robots file, one description of who I am, canonical URLs that can't drift from the address bar. Then I pointed Search Console and PageSpeed Insights at the result and let them tell me what I'd missed.

This is what's actually wired up, and the two warnings from those tools that looked like bugs and turned out to be nothing.

A subfolder, not a subdomain

The blog lives at anuphaldar.com/blog, not blog.anuphaldar.com. That second one exists as a DNS record — a leftover from an earlier plan — but it isn't a deployment target. A subfolder inherits the domain's existing authority; a subdomain has to earn its own from a standing start. There was no reason to split that.

blog.anuphaldar.com still resolves, though, so anything that hits it — a stale bookmark, a crawler that saw the name once — gets a 308 back to the real path:

src/proxy.ts
if (host.startsWith(BLOG_LABEL)) {
  const { pathname } = request.nextUrl
  const url = request.nextUrl.clone()
  url.hostname = host.slice(BLOG_LABEL.length)
 
  if (!isAsset(pathname)) {
    url.pathname =
      pathname === BLOG_BASE_PATH || pathname.startsWith(`${BLOG_BASE_PATH}/`)
        ? pathname
        : `${BLOG_BASE_PATH}${pathname === '/' ? '' : pathname}`
  }
 
  return NextResponse.redirect(url, 308)
}

blog.anuphaldar.com/my-post becomes anuphaldar.com/blog/my-post instead of a 404. The point isn't that anyone's actually hitting that subdomain — it's that duplicate hosts split a page's ranking signal between them, and closing that off costs one if block.

There's also a www branch in the same file, and checking it in Search Console taught me something I hadn't planned to learn: it never runs. Vercel's own domain redirect fires before this code does, so www.anuphaldar.com gets a 301 from Vercel and my 308 is dead weight in production. It still matters for anyone running the app on a different host — Vercel isn't the only place this could ever be deployed — but on the live site, it's doing nothing. I left it in.

One sitemap, one robots.txt, one llms.txt

The blog is a section of the site, not a second site, so it doesn't get its own crawl surface. sitemap.xml, robots.txt and llms.txt each exist exactly once, and the blog's entries are appended to the same files rather than duplicated in blog-specific versions:

src/app/sitemap.ts
return [
  { url: SITE_ORIGIN, lastModified: homeLastModified, changeFrequency: 'monthly', priority: 1 },
  { url: blogUrl('/'), lastModified: newestPost ? new Date(newestPost) : homeLastModified, changeFrequency: 'weekly', priority: 0.9 },
  ...posts.map((post) => ({
    url: blogUrl(`/${post.slug}`),
    lastModified: new Date(post.updated || post.published),
    changeFrequency: 'monthly' as const,
    priority: 0.8,
  })),
]

The one deliberate choice in there is lastModified. It's tempting to just stamp every entry with the current build time — Next rebuilds on every deploy, so that's the value sitting right there. I didn't, because a redeploy would then tell Google every single post changed, every single time, whether or not a word of it did. Do that a few times and a crawler learns to stop trusting your dates, which is the opposite of what lastModified is for. Each post's date comes from its own published or updated field instead, so the sitemap only claims a change when one actually happened.

robots.txt disallows exactly one path, /blog/og — that's the route that renders social-card images, and crawlers only ever fetch it as an image referenced from og:image, never as a page. Keeping it out of the crawl doesn't help the images; it just stops a route with no content on it from eating crawl budget.

llms.txt is the odd one out, since there's no established framework support for it. It's a plain route handler that generates Markdown from the same post registry the sitemap reads, so an LLM crawler gets contact details, skills and an article list from about 2 KB of prose instead of having to infer them from the page layout. It's also the one file here I keep honest by hand rather than deriving from layout.tsx — worth a look if you're adding one to your own site, since it's easy to let it drift.

One of each, on purpose

It would have been just as easy to give the blog its own sitemap.xml under /blog/. The only reason not to is that it's a second thing to keep in sync with the first, for a section that's part of the same host. If your blog is genuinely a separate deployment or subdomain, that math changes.

Canonical URLs come from one function, not memory

Every internal link and every canonical tag goes through one of two helpers instead of a hand-typed /blog/... string:

src/lib/blog.ts
export function blogPath(path: string = '/'): string {
  return path === '/' ? BLOG_BASE_PATH : `${BLOG_BASE_PATH}${path}`
}
 
export function blogUrl(path: string = '/'): string {
  return `${SITE_ORIGIN}${blogPath(path)}`
}

Boring by design. The value isn't in what the function does, it's that /blog exists as a string literal in exactly one place, so the whole section could move to /writing or /articles by editing that one constant instead of grepping the codebase for a path I might not find every instance of.

The part that actually cost me a bug: Next merges a page's metadata object shallowly, not deeply. I first had the RSS <link rel="alternate"> set once on the blog layout and each post's canonical set individually on the page. That looks fine until you remember a page's alternates block replaces the layout's outright — it doesn't merge with it — so every post silently lost the feed link the moment it declared its own canonical. The fix was to stop setting them in two places:

src/lib/blog.ts
export function blogAlternates(path: string = '/') {
  return {
    canonical: blogUrl(path),
    types: {
      'application/rss+xml': [{ url: blogUrl('/rss.xml'), title: BLOG_NAME }],
    },
  }
}

Anything that needs a canonical calls this and gets the feed link along with it, whether it remembers to ask for it or not. If you're building metadata by hand across a layout and its pages, this is worth checking before you ship — it won't throw an error, it'll just quietly not be there when you view source.

One Person, referenced everywhere else

The root layout emits a Person node in JSON-LD once, with a stable @id:

{ "@type": "Person", "@id": "https://anuphaldar.com/#person", "name": "Anup Haldar", "...": "..." }

Every blog post's BlogPosting schema then points author and publisher at that same @id instead of describing me again:

author: { '@id': AUTHOR.schemaId },
publisher: { '@id': AUTHOR.schemaId },

The alternative — writing out name, job title and socials fresh in every post's structured data — works fine right up until one post's copy drifts from another's, and now there are two machine-readable descriptions of the same person disagreeing with each other. One node, referenced everywhere, can't drift.

Social cards without an image file per post

opengraph-image.tsx is the usual Next.js way to generate an OG image, but it produces one image per route, and I didn't want a file per post for a card that's 90% shared layout and 10% headline. Instead there's one edge route that takes the title as a query parameter:

src/app/blog/og/route.tsx
export function GET(request: Request) {
  const params = new URL(request.url).searchParams
  const title = clamp(params.get('title'), 110, "Anup Haldar's Blog")
  // ...renders the card with `title` in it
}

Every post's metadata builds its own image URL — /blog/og?title=...&eyebrow=... — and gets a card with its own headline for free. The route also skips fetching a font or an avatar during the render, on purpose: a network call inside an image response is exactly the kind of thing that makes a crawler time out fetching your og:image, and a typographic card that renders instantly beats a nicer one that sometimes doesn't load at all. Since the output is a pure function of the query string, it's cached for a year at the edge.

What Search Console and PageSpeed actually flagged

This is the part I actually learned something from. Two warnings showed up that both read like real problems on first glance, and neither one was.

"Reduce unused JavaScript" is React, not me

PageSpeed Insights flags roughly 47 KiB of unused JavaScript and names two files as the source. Both are framework bundles:

ChunkWhat it is
~70 KiBReact + ReactDOM (bundled by Next itself)
~38 KiBThe App Router's own client runtime

For comparison, everything I actually wrote — navigation, the contact form, toast notifications, the theme switcher, the submission-rate limiter — compiles to one chunk of about 27 KB raw, 8 KiB over the wire. The icons are imported individually rather than as a sprite sheet, and MDX and the syntax highlighter never leave the build step. There's no app code sitting in that 47 KiB.

I checked two more things before accepting that, because "the framework is heavy" is also what a lazy audit looks like from the outside:

  • A ~112 KB core-js polyfill chunk shows up in the build. It's emitted inside <script noModule>, which means only browsers that can't run ES modules would ever request it — confirmed in the built HTML and by watching the network tab in a current browser, where it never fires.
  • Vercel does serve brotli, but not at the highest compression level. That React chunk arrives at 70.2 KiB over the wire versus 58.9 KiB when I compress the same file locally at brotli's max quality. That gap isn't something a config file in this repo controls.

Sometimes the fix is to stop looking

This audit will keep reappearing on every PageSpeed run for as long as the site uses React and the App Router, and it will keep reading like an actionable bug. Once you've traced the bytes to the framework and ruled out the usual culprits — an unshaken polyfill, a missed compression setting — the correct response is to stop re-profiling it, not to keep chasing a number that isn't going to move.

"Page with redirect" on three URLs that are supposed to redirect

Search Console's coverage report listed exactly three URLs under "Page with redirect": http://anuphaldar.com/, https://www.anuphaldar.com/ and http://www.anuphaldar.com/. All three correctly 301 or 308 to https://anuphaldar.com/, which is the canonical URL that's actually indexed.

The first instinct is to read that as an error to fix. It isn't one — it's an exclusion report, Google's way of saying "I found this URL, it redirects, so I indexed the destination instead of this one," which is exactly the intended behaviour. Clicking "Validate fix" confirms it: validation always ends in "failed," because Google re-crawls those three URLs expecting them to have stopped redirecting, and by design they never will.

Checking it properly meant tracing all three redirect chains to a 200 on the apex, which they do, and confirming the canonical is what's actually in Google's index via URL Inspection, which it is. That's the whole fix: verify the destination is indexed, then stop opening that report every week expecting a different number.

The one thing that actually was a fix

Not everything PageSpeed flagged was a false alarm — image weight was real. The favicon was a 256×214 source PNG being served directly at every size, including the browser tab, so a visitor was downloading roughly 26 KB of image to fill 16 pixels. That got replaced with actually-sized files — a 32×32 icon for the tab, a 192×192 for Android, a separate opaque one for iOS's home screen, since iOS composites the icon over black and a transparent PNG there just reads as a black square. The couple of project screenshots on the homepage got the same treatment, recompressed down by roughly a quarter with no visible difference. Both are generated by a small script now rather than hand-exported, so the next image doesn't regress the same way.

What I'd check on any Next.js site

If you're setting this up from scratch, the order that would have saved me the most time:

  1. Decide subfolder vs. subdomain before you write a single route — moving a section later means either a redirect plan or an authority reset.
  2. One sitemap.ts, one robots.ts for the whole host, even if a section feels logically separate.
  3. Base lastModified on real content dates, never on build time.
  4. Put canonical and feed alternates in one function if a layout and its pages both touch metadata — the shallow-merge gotcha above is easy to hit without noticing.
  5. Give structured data across pages a shared @id for anything that's really one entity, instead of re-describing it per page.
  6. Read what PageSpeed and Search Console are actually naming before treating a flagged number as your bug. Sometimes it's the framework, sometimes it's an artifact of the report itself, and sometimes — like the favicon — it's genuinely yours.

Frequently asked questions

Does putting a blog on a subdomain hurt SEO compared to a subfolder?

It isn't automatically fatal, but a subfolder starts with the domain's existing authority and a subdomain has to build its own from zero. Unless there's an infrastructure reason to split hosts — a different team, a different deploy pipeline — a subfolder is the easier default.

What does Search Console's 'Page with redirect' report actually mean?

That Google found a URL, followed its redirect, and indexed the destination instead — an exclusion, not an error. If the listed URLs are ones you meant to redirect (a www variant, an old host), there's nothing to fix, and "Validate fix" will fail forever because the redirect isn't going away.

Is 'Reduce unused JavaScript' in PageSpeed Insights always something I should fix?

Only if it's naming your own bundles. Open the report and check which files it points at — on a React site with any framework runtime, a meaningful share of that number is routinely the framework itself, which isn't something app code can trim.

Do I need a separate sitemap for a blog section of my site?

Not if the blog lives on the same host as the rest of the site. One sitemap covering every URL is simpler to keep correct than several that need to stay in sync with each other.

What is llms.txt and is it worth adding?

A plain-Markdown summary of a site, at /llms.txt, aimed at AI crawlers and assistants rather than search engines. There's no ranking guarantee attached to it yet, but it's a route handler that reads from the same data your sitemap already uses, so the marginal cost of adding one is low.

Written by

Anup Haldar

Full Stack Developer in Uttarakhand, India, with 3+ years of experience building production-ready web applications with TypeScript, React, Next.js, and Node.js.

Work with me
PreviousHow to use Claude Code: a complete guide for developers

On this page

  • A subfolder, not a subdomain
  • One sitemap, one robots.txt, one llms.txt
  • Canonical URLs come from one function, not memory
  • One Person, referenced everywhere else
  • Social cards without an image file per post
  • What Search Console and PageSpeed actually flagged
  • What I'd check on any Next.js site
  • Frequently asked questions

Anup Haldar

Full Stack Developer in Uttarakhand, India, with 3+ years of experience building production-ready web applications with TypeScript, React, Next.js, and Node.js.

Blog

  • All articles
  • RSS feed
  • Sitemap

Elsewhere

  • Portfolio home
  • Projects
  • Experience
  • Get in touch

© 2026 Anup Haldar.