# Nuxt 3 News Site with Route Rules and SEO Head

> A concept project with a working demo: a news site written in Vue 3 and Pinia and structured like a Nuxt 3 app, with per-route caching rules, server API routes, pagination, search and generated SEO tags. The demo runs Vue client-side inside this Next.js site and simulates the Nuxt server; the case study describes the real Nuxt 3 design.

- Author: Sami Ullah (https://samiullah.tech/about)
- Type: Concept project
- Role: Full build
- Stack: TypeScript, Vue 3, Pinia, Next.js, React.js
- Year: 2026
- Updated: 2026-11-13
- Canonical: https://samiullah.tech/work/nuxt-news-site

## Overview

A news site has an unusual mix of needs. Pages must be in the HTML for search engines, the front page changes every few minutes, articles almost never change after publishing, and some parts (a breaking-news strip) must be live no matter what. Nuxt 3 handles all of this with one config object, `routeRules`, plus server rendering by Nitro.

I built this concept to show how I would design that site. The [live demo](/lab/nuxt-news-site) is honest about what it is: Vue 3 and Pinia running client-side inside this Next.js portfolio. It simulates the Nuxt server so you can see what normally happens out of sight: which rule matched, whether the page came from cache, and which head tags were generated.

## My role

Everything: the content model, the route and caching plan, the Vue components, the simulated Nitro handler and this write-up. It is a concept build, not client work. The publication, "Acme Daily", and every article in it are fictional.

## The problem

Rendering every request on the server is simple but wasteful: a popular front page would be rebuilt thousands of times a minute with the same result. Caching everything for an hour is cheap but wrong: a new story would not show up, and a corrected headline would stay wrong.

The design needs different answers per route:

- **Front page:** changes often, read constantly. Cache briefly and refresh in the background.
- **Section pages:** change less often. Cache for a few minutes.
- **Articles:** written once, sometimes corrected. Cache for an hour and purge on edit.
- **Search:** one page per query, endless variations. Never cache, and keep out of the index.
- **Saved articles:** belong to one reader's browser. No server render at all.
- **Breaking news:** must be current even when the page around it is a cached copy.

## Approach & architecture

```flow
# Request to a cached route
Browser -> Nitro -> match routeRules -> cache lookup
cache lookup -> HIT -> cached HTML + payload
cache lookup -> STALE -> cached HTML now -> regenerate in background
cache lookup -> MISS -> render page -> useFetch -> server/api -> CMS
```

In the production design the whole caching plan is one block in `nuxt.config.ts`. The demo's `server/nitro.ts` uses the same object, so the panel in the demo reads exactly what the config says:

```ts
// nuxt.config.ts (production design)
export default defineNuxtConfig({
  routeRules: {
    "/": { swr: 60 },
    "/category/**": { swr: 300 },
    "/article/**": { isr: 3600 },
    "/search": { headers: { "cache-control": "no-store" } },
    "/saved": { ssr: false },
    "/api/breaking": { headers: { "cache-control": "no-store" } },
  },
});
```

**Data through server routes.** Pages never talk to the CMS directly. They call `useFetch("/api/articles/:slug")`, and the handler in `server/api/articles/[slug].get.ts` talks to the CMS. During server rendering Nitro calls that handler in-process, with no HTTP hop, and the result is serialised into the payload so the browser does not fetch it again while hydrating. In the demo, each page's `data` function plays the role of `useFetch`, and `usePageData()` reads the result from the payload.

**SEO in the render.** Each page sets its head with `useSeoMeta` during the server render, so title, description, canonical URL, Open Graph tags and JSON-LD are in the HTML. In the production design the article page looks like this:

```ts
// pages/article/[slug].vue, <script setup> (production design)
const route = useRoute();
const { data } = await useFetch(`/api/articles/${route.params.slug}`);
if (!data.value) throw createError({ statusCode: 404, statusMessage: "Article not found" });

useSeoMeta({
  title: data.value.title,
  description: data.value.standfirst,
  ogType: "article",
  ogImage: data.value.image.url,
});
useHead({
  link: [{ rel: "canonical", href: `https://news.example.com/article/${data.value.slug}` }],
  script: [{ type: "application/ld+json", innerHTML: JSON.stringify(newsArticleLd(data.value)) }],
});
```

The demo's "Generated &lt;head&gt;" tab shows the same output for whichever page you are on, including the `NewsArticle` JSON-LD with headline, image, dates, author and publisher.

**A live fragment inside a cached page.** The breaking-news strip is a client-only component that calls `/api/breaking` from the browser on an interval. The cached front page never contains it, so a 60-second cache can never show an old breaking line.

## Key challenges

- **Stale content after an edit.** With stale-while-revalidate, the first visitor after the TTL still gets the old page. That is the right trade for a front page, but not for a correction on an article. In the production design the CMS sends a webhook on publish or edit, and a small server route purges that article's cache key and the front page. The demo's "Purge this URL" button is that call.
- **Cache keys and query strings.** `/category/science?page=2` is a different page from `/category/science`. Nitro keys the cache by path and query, so pagination works, but it also means a crawler adding random query parameters can fill the cache. Production would whitelist known parameters (`page`, `q`) before the cache lookup.
- **Not caching errors.** A 404 for a slug that is about to be published must not be stored for an hour. The demo's handler only stores 200 responses, and you can check it by typing an unknown article path into the address bar.
- **Search and indexing.** Search pages are rendered on the server for speed and shareable URLs, but they carry `robots: noindex, follow` so thin result pages stay out of search engines.
- **Being honest about the demo.** I could not run Nitro inside a Next.js page. Instead, I kept the parts that matter (route rule matching, cache states, payload, head) as small readable modules, and labelled the demo clearly as a simulation.

## Tech stack

- **Demo:** [Vue 3](/skills/vue) with the Composition API and template strings, Pinia for state, TypeScript, mounted in this [Next.js](/skills/nextjs) site through a small React wrapper. The cache, clock and CMS are saved in `localStorage`.
- **Production design:** Nuxt 3 with Nitro for server rendering and `routeRules`, server API routes in front of a headless CMS, Redis as Nitro's cache storage for SWR routes, the host's ISR for articles, and a publish webhook for purging.

## Results

- A working demo with a front page, six section pages with pagination, article pages, search, saved articles and a 404 page, all driven by one route-rule table.
- Each response shows its cache state (HIT, STALE, MISS or BYPASS), response headers and generated head tags, so the effect of every rule can be checked by hand.
- The CMS buttons make the hard case visible: an edited headline stays stale until the TTL passes or the URL is purged.
- The Vue code under "View source" is the code running in the demo.

I have not measured Lighthouse scores or response times for a deployed Nuxt build, because there is none yet. This is a concept build without real users.

## Try the demo

[Open the Nuxt news site demo](/lab/nuxt-news-site). Things to try:

1. Open the front page twice. The first load is a MISS, the second a HIT. Press **+1h** and reload: you get STALE, and the log shows the background regenerate.
2. Open an article, press **Edit this headline**, then reload. The old headline is still served. Press **Purge this URL** and reload to see the new one.
3. Search for `open data` and look at the head tab: the page has `noindex, follow`.
4. Save an article, then open **Saved**: that route is client-rendered with `ssr: false`.

## What I would do next

- Build the real Nuxt 3 app from these modules: `.vue` files for the pages, `server/api` handlers, and the same `routeRules`.
- Add the publish webhook that purges the article and front page keys, with a signature check.
- Generate a sitemap and per-section RSS feeds from the same server routes.
