DEV Community

Cover image for What SEO Should a Modern Web Framework Handle Automatically?
Ranjeet Kumar Jena
Ranjeet Kumar Jena

Posted on

What SEO Should a Modern Web Framework Handle Automatically?

21 August 2026

Every framework has an SEO story now, and almost all of them are the same story: a helper that writes <meta> tags. Next has Metadata, Nuxt has useHead, Astro has whatever you import this month. They are fine. They are also the least interesting part of the problem, because writing a <title> was never what broke anyone's search traffic.

The interesting question is a different one: what does the framework do when you do nothing at all?

A metadata API is opt-in by definition. It helps on the pages you remembered. The failures that actually cost traffic are the ones nobody remembered, because nothing errored — a 200 status on a page that doesn't exist, a canonical URL pointing at http://, a link to a page the build silently skipped. None of those throw. None of them show up in a test. They show up in Search Console eleven weeks later.

So here is a better test for a framework's SEO story:

If a competent developer builds a content site and never once thinks about search engines, what is already correct?

I want to work through the categories that answer that question, using Stoneware as the worked example — partly because I built it, and mostly because its defaults were chosen against exactly this test. Every code sample and every output below is real.


1. The document has to be complete in the first response

This is the foundation, and it is upstream of everything else. If your content is assembled on the client, then everything downstream — canonical tags, structured data, sitemaps — is decoration on a page a crawler may or may not fully see.

The honest version of this claim is measurable, not rhetorical. Here is a 21-route content site, built three ways from byte-identical content:

                     JS on an article page    pages with zero JS
  ─────────────────────────────────────────────────────────────
  Stoneware                        0 B                20 of 21
  Astro                            0 B                20 of 21
  Next.js (App Router)          576 KB                 0 of 21
Enter fullscreen mode Exit fullscreen mode

Stoneware and Astro send a document and nothing else. Next sends 576 KB of JavaScript to a page with no interactive element on it, and re-encodes the article a second time inside the HTML as an RSC payload.

The test you can run yourself takes one command:

curl -s https://your-site.example/some-article | grep "a sentence from the article"
Enter fullscreen mode Exit fullscreen mode

If that finds nothing, no amount of metadata will help you.

Stoneware's rule here is structural rather than advisory: a file under routes/ is never handed to the bundler, so it cannot reach the client. Interactivity is opt-in per directory — a component under islands/ hydrates, everything else is a string the server produced.

What a framework should handle automatically: rendering to complete HTML by default, with client JavaScript as the exception you ask for.


2. Status codes, and the soft 404

This is the most common indexing bug on content sites and almost nobody talks about it.

A dynamic route matches any slug. /blog/[slug] happily matches /blog/asdfghjkl. Your template then looks up the post, doesn't find it, and — if you're not careful — renders "Post not found" with a 200 OK.

That page is now indexable. Google will crawl it, and it may index it. Worse, the pattern generates infinitely many of them.

The fix has to be ergonomic or people won't use it. In Stoneware it's one call that throws:

import { notFound, type PageProps } from "stoneware";

export default function Post({ params }: PageProps) {
  const post = getPost(params.slug);
  if (!post) notFound();      // real 404, with your error page rendered into it

  return <article>{post.title}</article>;
}
Enter fullscreen mode Exit fullscreen mode

Because it throws rather than returns, it works from a helper three calls deep without every function in between having to pass a sentinel back up. And because its return type is never, TypeScript narrows post to present afterwards — no non-null assertion.

Here is what the framework answers without being asked:

  /no-such-page      404      the _404 page, Cache-Control: no-store
  /_404              404      a convention, never servable as a page
  notFound()         404      your _404 page, correct status
  a thrown error     500      the _500 page, no-store
Enter fullscreen mode Exit fullscreen mode

Two details worth stealing regardless of framework:

  • Error responses are no-store. A 404 cached by a CDN outlives the deploy that adds the missing page. That's a self-inflicted outage with a long tail.
  • A leading underscore means a file isn't servable. Without that rule, routes/_404.tsx would answer a real request at /_404 with a 200 — your error page, indexable as content.

What a framework should handle automatically: making the correct status the easy one, and never serving its own conventions as pages.


3. Canonical URLs that survive a reverse proxy

This one is invisible locally and bites almost every production deployment.

Every platform that terminates TLS — Render, Railway, Fly, Vercel, nginx — forwards a plain HTTP request to your app. So new URL(request.url) reports http:// for a site served over https://. Anything absolute you build from it is now wrong:

  • <link rel="canonical"> pointing at http://
  • og:image on the insecure origin
  • sitemap entries on the wrong scheme
  • OAuth redirect URIs that don't match

A canonical tag pointing at http:// tells a crawler that your https:// page is a duplicate of a page that redirects. Nothing errors. The site looks perfect to you.

The framework can't just trust the forwarded headers — they're trivially forged by anyone who can reach your app directly, and a spoofed X-Forwarded-Host poisons every absolute URL you emit, including password-reset links. So it has to be a decision, not a default:

export default defineConfig({
  trustProxy: "proto",   // or STONEWARE_TRUST_PROXY in the environment
});
Enter fullscreen mode Exit fullscreen mode

"proto" honours the forwarded scheme only — safe on any host, and enough to fix the common case. true also honours the forwarded host, which requires a proxy you actually control.

What a framework should handle automatically: giving routes a URL that is already the public one, and making the proxy question a single explicit setting rather than something each developer rediscovers.


4. The metadata API — the part everyone builds

This is table stakes, so I'll be brief. The value isn't in emitting tags; it's in the mistakes the API makes impossible.

export function head() {
  return seo({
    title: "Java Quiz",
    description: "Practice Java questions online.",
    canonical: "https://example.com/quiz/java",
  });
}
Enter fullscreen mode Exit fullscreen mode
<title>Java Quiz</title>
<meta name="description" content="Practice Java questions online.">
<link rel="canonical" href="https://example.com/quiz/java">
Enter fullscreen mode Exit fullscreen mode

Three fields in, three tags out — omitted fields produce no tag rather than an empty one.

The four things worth automating, all of which are silent failures by hand:

  1. Relative image paths become absolute. A relative og:image is dropped by most crawlers, and you find out when someone shares the link.
  2. Open Graph uses property, not name. Writing name="og:title" is the single most common hand-written metadata bug and it does nothing.
  3. Social titles fall back to the top-level ones, so the common case is written once instead of three times.
  4. The card type defaults correctlysummary_large_image when there's an image, summary when there isn't, because a large-image card with no image renders as a bare link.

There's also a nice trick available to a framework that owns the render: it can tell when you've called the metadata helper from the wrong place. Tags in <body> are read by nothing:

[stoneware] seo() was called while rendering /about, not from its head export.
  Those tags land in <body>, where nothing reads them. Move the call into:
    export function head(props) { return seo({ ... }); }
Enter fullscreen mode Exit fullscreen mode

What a framework should handle automatically: the protocol details, the fallbacks, and telling you when the tags landed somewhere useless.


5. sitemap.xml — and why auto-generation is the wrong default

Here's a place I think most frameworks get the philosophy wrong.

A framework knows every route pattern in your project. It is technically trivial to enumerate them into a sitemap. Several frameworks do, and it feels like a feature.

It's a mistake. A sitemap is not a list of routes that exist — it's a list of pages you are asking a search engine to index. Those are different sets, and the difference is editorial:

  • a checkout confirmation page
  • anything behind a login
  • paginated archives you'd rather have crawled through links
  • a legal page you have to host but don't want ranking

All routes. None of them sitemap entries. A framework that guesses produces a file that is confidently wrong, and confidently wrong is worse than absent.

So the right split is: the framework owns XML correctness; you own the editorial decision.

import { sitemap } from "stoneware";
import { SITE_URL } from "../lib/site.ts";
import { POSTS } from "../lib/posts.ts";

export function GET(): Response {
  return sitemap(
    [
      { url: "/", changeFrequency: "weekly", priority: 1 },
      ...POSTS.map((post) => ({
        url: `/blog/${post.slug}`,
        lastModified: post.published,
      })),
    ],
    { origin: SITE_URL },
  );
}
Enter fullscreen mode Exit fullscreen mode

It's a route, not a config file — so it can query your database and stay correct with no build step. Derive entries from the same data the pages render and the sitemap cannot drift from the site.

The parts that are genuinely easy to get wrong, and are therefore worth owning:

  • XML escaping, including the apostrophe. ' is legal in a URL and illegal unescaped in XML. HTML-escaping helpers get this wrong and produce a document some parsers reject.
  • Relative URLs are refused, not emitted. A relative <loc> parses fine and no crawler can use it.
  • Date-only strings pass through unchanged. Round-tripping 2026-08-13 through Date shifts it by the local UTC offset and publishes the wrong day for half the world.
  • Limits are enforced — duplicates collapse, out-of-range priority is refused, and >50,000 entries fails with a pointer to sitemap indexes.

What a framework should handle automatically: the file format. Not the contents.


6. robots.txt should be a route too

There's a persistent instinct to make robots.txt a static file. Don't. The moment you have a staging environment you want Disallow: /, and the moment you have a sitemap you want its absolute URL in there.

The scaffolded version is deliberately boring:

export function GET(_context: ActionContext): Response {
  const body = `User-agent: *
Allow: /

Sitemap: ${siteURL("/sitemap.xml")}
`;

  return new Response(body, {
    headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, no-cache" },
  });
}
Enter fullscreen mode Exit fullscreen mode

Because it's ordinary code, staging is a two-line change:

const isProduction = Bun.env.NODE_ENV === "production";
const body = isProduction ? `User-agent: *\nAllow: /\n\nSitemap: ...` : `User-agent: *\nDisallow: /\n`;
Enter fullscreen mode Exit fullscreen mode

Serving a permissive robots.txt from a staging domain is one of the fastest ways to get duplicate content indexed under a URL you don't control.

What a framework should handle automatically: scaffolding both files on day one, so they exist in the first commit rather than being added after the first indexing problem.


7. Broken links, caught at build time

Internal links that point nowhere leak crawl budget and dead-end users, and they're invariably found by a crawler weeks after they shipped.

A framework that prerenders your site already has everything needed to check this — it knows every file it wrote and can read every link it emitted.

stoneware export --strict
Enter fullscreen mode Exit fullscreen mode

The export follows every same-origin href and src in the pages it wrote and reports anything resolving to nothing. src matters as much as href: a missing stylesheet or script chunk is the same class of failure and much easier to ship unnoticed. It also reports any route skipped for lacking a staticPaths() export. With --strict, either one fails the build instead of printing a note you scroll past.

What a framework should handle automatically: validating its own output before you deploy it.


8. Crawl budget is a caching problem

Search engines re-crawl. If every re-crawl transfers the full document, you're paying for it in bandwidth and in crawl budget spent re-reading pages that didn't change.

Stoneware gives every page a weak ETag derived from the rendered HTML, with Cache-Control: public, no-cache. That combination is widely misread: no-cache does not mean "don't store", it means "revalidate before use". A crawler with a stored copy sends If-None-Match and gets an empty 304.

The validator changes exactly when the page changes — there's no max-age to tune and no window in which a published change is invisible.

I'll be honest about the cost, because it's real: since the validator is a hash of the output, producing it means producing the output. A 304 still runs the route and renders the page. Measured on a 14 KB document, a 304 costs about four-fifths of what the 200 costs. The saving is bandwidth, not server work. Static assets are the opposite — they're content-hashed, served immutable for a year, and a 304 there is about twenty times cheaper than sending the file.

What a framework should handle automatically: correct validators on every response, so revalidation works without configuration.


9. Analytics: the part nobody wants to write down

Here's where I have to be honest about a trade-off rather than sell you a feature.

Stoneware ships a restrictive Content-Security-Policy on by default:

default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:;
font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self';
form-action 'self'; frame-ancestors 'none'
Enter fullscreen mode Exit fullscreen mode

This is possible because the framework never emits inline executable script — hydration payloads are JSON in a non-executable block, not string-concatenated into a <script> tag. So script-src 'self' just works, with no nonce plumbing.

And it blocks Google Analytics. Out of the box, gtag.js will not load. That is not an oversight; it's the point of a default-deny policy. But it means the framework owes you a clear path rather than a shrug.

The policy is additive — you name extra origins per directive and the defaults survive:

export default defineConfig({
  csp: {
    scriptSrc: ["https://www.googletagmanager.com"],
    connectSrc: ["https://*.google-analytics.com", "https://*.analytics.google.com"],
    imgSrc: ["https://*.google-analytics.com"],
  },
});
Enter fullscreen mode Exit fullscreen mode

which produces:

script-src 'self' https://www.googletagmanager.com
img-src 'self' data: https://*.google-analytics.com
connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com
Enter fullscreen mode Exit fullscreen mode

Note that 'self' and every directive you didn't mention are preserved — you're widening a policy, not replacing one.

The part that catches people: allowing the domain is not enough. The standard GA snippet includes an inline <script> block that initialises dataLayer, and script-src without 'unsafe-inline' blocks it regardless of which domains you allow. You have two honest options:

  1. Move that initialisation into a file under public/ and load it with <script src="/analytics.js"> — same-origin, so 'self' covers it.
  2. Add 'unsafe-inline' to script-src, and understand that you have just disabled your main defence against XSS to save one file.

I think a framework's job here is to make option 1 easy and option 2 explicit, rather than to quietly ship 'unsafe-inline' so that every analytics snippet pastes cleanly. A lot of frameworks make the opposite choice by default and never mention it.

It's also worth saying: a server-first site with no client JavaScript has unusually good options here. Server-side request logging via an observer hook gives you page views with no third-party script, no cookie banner, and no CSP exception at all:

export default defineConfig({
  observe: (event) => {
    analytics.pageview({ route: event.route, status: event.status, ms: event.durationMs });
  },
});
Enter fullscreen mode Exit fullscreen mode

event.route is the pattern/blog/[slug], not /blog/hello-world — which is what you actually want as a metrics dimension.

What a framework should handle automatically: a secure default, an additive escape hatch, and documentation honest enough to say which of your tools it breaks.


What a framework should not do

A short list, because overreach here is common:

  • It should not write your metadata. Making tags easy is the job; deciding what they say is not.
  • It should not audit your content. Heading structure, alt text, internal linking, whether the page is worth reading — none of that is checkable by a build tool, and tools that claim to check it mostly count characters.
  • It should not enumerate your sitemap. See above.
  • It should not promise rankings. Any framework that markets itself on search rankings is making a claim about someone else's algorithm that it cannot keep. The honest version is: here is what is in the HTML, verify it yourself.

The scorecard

If you're evaluating a framework — or building one — these are the questions I'd ask, in roughly the order they'll cost you:

Should be automatic?
Content present in the first HTML response Yes, by default
A dynamic route with no data returns 404 Yes, one obvious call
Framework conventions aren't servable as pages Yes, always
Error responses are never cached Yes, always
URLs are correct behind a TLS-terminating proxy One explicit setting
Metadata protocol details and fallbacks Yes
Warning when metadata lands somewhere useless Yes, in development
Sitemap XML correctness Yes
Sitemap contents No — yours
robots.txt scaffolded and environment-aware Scaffolded, then yours
Broken internal links caught at build Yes, with a flag to fail
Correct cache validators on every response Yes
Third-party analytics working out of the box No — and it should say so

Most frameworks score well on exactly one row of that table: metadata. That's the row that's easiest to build and the one that matters least, because it's the row you were already thinking about.

The rows that quietly cost traffic are the ones where the failure is a 200 status, a correct-looking http://, or a link to a page that isn't there — all silent, all invisible in development, all discovered by a crawler long after they shipped.

Those are the ones worth automating.


Stoneware is a Bun-native, server-first web framework — MIT, stoneware-dev/stoneware-core. Every measurement above comes from a reproducible benchmark of a 21-route content site; the harness is public.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev

This is also a good case for CI checks: route returns 200 only for eligible combinations, canonical matches the route, the sitemap contains no previews, and structured data matches the visible page.