Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · Vue 3 · Pinia · Next.js

Nuxt 3 News Site with Route Rules and SEO Head Demo

A news front page, section pages, articles and search, written in Vue 3 and Pinia and laid out like a Nuxt 3 app. A side panel shows which route rule served each page, whether it came from the cache (HIT, STALE, MISS) and the head tags it produced. Publish or edit a story, fast-forward the clock and watch cached pages catch up.

Read the case study →

Interactive demo

samiullah.tech/lab/nuxt-news-site

$ mounting Vue app…

## How it works

Every navigation goes through a simulated Nitro handler. It matches the URL against routeRules (the same object that would sit in nuxt.config.ts): the front page uses swr: 60, sections swr: 300, articles isr: 3600, search is never cached and /saved has ssr: false.

On a MISS the handler “renders”: it runs the page's data function, which calls the server API routes through $fetch, builds the head with a useSeoMeta-style helper and stores the JSON payload. A stale entry is served at once and regenerated in the background, which is exactly what stale-while-revalidate means.

The breaking-news strip is fetched from the browser only, so it stays current even when the page around it is a cached copy. That split (cached shell, live fragment) is the main design idea of the site.

## View source

The code running in the window above, exactly as deployed.

app/lab/nuxt-news-site/app/server/nitro.ts81 lines
/**
 * Route rules and the page cache, modelled on Nitro (the server engine under Nuxt 3).
 * `routeRules` is copied as-is into nuxt.config.ts in the production design.
 */

export interface RouteRule {
  /** Cache the rendered page for N seconds, then serve stale while one request regenerates it. */
  swr?: number;
  /** Same idea on the hosting CDN (Vercel/Netlify ISR); survives across server instances. */
  isr?: number;
  /** false = no server render: send the app shell and render in the browser. */
  ssr?: boolean;
  headers?: Record<string, string>;
}

export const routeRules: Record<string, RouteRule> = {
  "/": { swr: 60 },
  "/category/**": { swr: 300 },
  "/article/**": { isr: 3600 },
  "/search": { headers: { "cache-control": "no-store" } },
  "/saved": { ssr: false },
  "/api/breaking": { headers: { "cache-control": "no-store" } },
};

export type RenderMode = "swr" | "isr" | "ssr" | "spa";
export type CacheStatus = "HIT" | "STALE" | "MISS" | "BYPASS";

/** Exact match first, then the longest "/prefix/**" pattern. */
export function matchRouteRule(path: string): { pattern: string; rule: RouteRule } | null {
  const exact = routeRules[path];
  if (exact) return { pattern: path, rule: exact };
  const wild = Object.keys(routeRules)
    .filter((p) => p.endsWith("/**"))
    .sort((a, b) => b.length - a.length)
    .find((p) => {
      const base = p.slice(0, -3);
      return path === base || path.startsWith(base + "/");
    });
  return wild ? { pattern: wild, rule: routeRules[wild]! } : null;
}

export function renderMode(rule: RouteRule | undefined): { mode: RenderMode; ttl: number | null; label: string } {
  if (rule?.ssr === false) return { mode: "spa", ttl: null, label: "Client-side only (ssr: false)" };
  if (rule?.isr) return { mode: "isr", ttl: rule.isr, label: `ISR, regenerate after ${rule.isr}s` };
  if (rule?.swr) return { mode: "swr", ttl: rule.swr, label: `SSR + SWR cache, ${rule.swr}s` };
  return { mode: "ssr", ttl: null, label: "SSR on every request, not cached" };
}

export interface CacheEntry<P> {
  renderedAt: number;
  payload: P;
}

export function cacheStatus(entry: CacheEntry<unknown> | undefined, ttl: number | null, now: number): CacheStatus {
  if (ttl === null) return "BYPASS";
  if (!entry) return "MISS";
  return now - entry.renderedAt < ttl * 1000 ? "HIT" : "STALE";
}

/** The response headers this rule produces (what curl -I would show). */
export function responseHeaders(
  mode: RenderMode,
  ttl: number | null,
  status: CacheStatus,
  ageSec: number,
  rule: RouteRule | undefined,
): [string, string][] {
  const h: [string, string][] = [["content-type", "text/html; charset=utf-8"]];
  if (mode === "swr") {
    h.push(["cache-control", `s-maxage=${ttl}, stale-while-revalidate`]);
    h.push(["x-demo-cache", status], ["age", String(status === "MISS" ? 0 : ageSec)]);
  } else if (mode === "isr") {
    h.push(["cache-control", "public, max-age=0, must-revalidate"]);
    h.push(["x-vercel-cache", status], ["age", String(status === "MISS" ? 0 : ageSec)]);
  } else if (mode === "spa") {
    h.push(["cache-control", "public, max-age=0, must-revalidate"]);
  }
  for (const [k, v] of Object.entries(rule?.headers ?? {})) h.push([k, v]);
  return h;
}
app/lab/nuxt-news-site/app/server/handler.ts128 lines
/**
 * The simulated Nitro request handler: match the route rule, answer from the page cache when
 * allowed, otherwise render (run the page's data fetch + head) and store the result.
 * Stale entries are served immediately and regenerated in the background (stale-while-revalidate).
 */
import { defineSeo, type PageContext, type Payload } from "../composables";
import { errorPage, matchPage } from "../router";
import type { NewsStore, ServedResponse, Tone } from "../stores/news";
import { parseUrl } from "../utils/url";
import { callApi, type Db } from "./api";
import { cacheStatus, matchRouteRule, renderMode, responseHeaders, type CacheStatus } from "./nitro";

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

function db(store: NewsStore): Db {
  return { articles: store.articles, breakingCalls: store.breakingCalls };
}

/** Server render of one URL: what `nuxi` does per request, minus the HTML string. */
export function render(store: NewsStore, url: string, where: "server" | "browser" = "server") {
  const { path, query } = parseUrl(url);
  const match = matchPage(path);
  const calls: string[] = [];
  let status = match ? 200 : 404;
  const ctx: PageContext = {
    params: match?.params ?? {},
    query,
    $fetch: <T>(u: string) => {
      const r = callApi<T>(u, db(store));
      calls.push(`  ↳ $fetch ${u} → ${r.status} (${r.file}, ${where})`);
      if (r.status !== 200) {
        status = r.status;
        return null;
      }
      return r.body;
    },
  };
  let page = match?.page ?? errorPage;
  let data: unknown = match ? page.data(ctx) : null;
  if (status !== 200 || data === null) {
    page = errorPage;
    data = null;
    status = status === 200 ? 404 : status;
  }
  const head = defineSeo(page.head(data, ctx));
  // Serialise like the real payload: plain JSON, detached from the live CMS objects.
  const payload = JSON.parse(
    JSON.stringify({ page: page.name, status, params: ctx.params, query, data, head, renderedAt: store.nowMs() }),
  ) as Payload;
  const renderMs = 18 + calls.length * 14 + Math.round(Math.random() * 12);
  return { payload, calls, renderMs };
}

const revalidating = new Set<string>();

function revalidateInBackground(store: NewsStore, url: string, ttl: number) {
  if (revalidating.has(url)) return;
  revalidating.add(url);
  store.write(`[bg] ${url} is stale: regenerating while the old copy is served`, "bg");
  setTimeout(() => {
    revalidating.delete(url);
    const { payload, renderMs } = render(store, url);
    if (payload.status === 200) {
      store.cache = { ...store.cache, [url]: { renderedAt: store.nowMs(), payload } };
      store.write(`[bg] ${url} regenerated in ${renderMs}ms, fresh for ${ttl}s`, "bg");
    }
  }, 900);
}

const TONE: Record<CacheStatus, Tone> = { HIT: "ok", STALE: "warn", MISS: "info", BYPASS: "muted" };

export async function handleRequest(store: NewsStore, raw: string): Promise<ServedResponse> {
  const { path, url } = parseUrl(raw);
  const at = store.nowMs();
  const rule = matchRouteRule(path);
  const { mode, ttl } = renderMode(rule?.rule);
  store.write(`GET ${url}`, "info");

  const entry = store.cache[url];
  const cache = cacheStatus(entry, ttl, at);
  let payload: Payload;
  let renderMs = 1;
  let calls: string[] = [];

  if ((cache === "HIT" || cache === "STALE") && entry) {
    payload = entry.payload;
  } else {
    const r = render(store, url, mode === "spa" ? "browser" : "server");
    ({ payload, renderMs, calls } = r);
    if (ttl !== null && payload.status === 200) store.cache = { ...store.cache, [url]: { renderedAt: at, payload } };
  }
  const age = entry && cache !== "MISS" ? Math.round((at - entry.renderedAt) / 1000) : 0;

  await sleep(cache === "HIT" || cache === "STALE" ? 60 : 140 + renderMs * 3);

  if (mode === "spa") store.write("  ↳ ssr: false → sent the app shell; the page renders in the browser", "muted");
  calls.forEach((c) => store.write(c, "muted"));
  const rendered = cache === "HIT" || cache === "STALE" ? "from cache" : mode === "spa" ? "shell" : `rendered ${renderMs}ms`;
  store.write(
    `  ${rule?.pattern ?? "(no rule)"} · ${mode}${ttl ? " " + ttl + "s" : ""} · ${cache} · ${rendered} → ${payload.status}`,
    payload.status !== 200 ? "err" : TONE[cache],
  );
  if (cache === "STALE" && ttl) revalidateInBackground(store, url, ttl);

  return {
    url,
    status: payload.status,
    mode,
    ttl,
    rulePattern: rule?.pattern ?? null,
    ruleJson: rule ? JSON.stringify(rule.rule) : "{}",
    cache: payload.status !== 200 ? "BYPASS" : cache,
    headers: responseHeaders(mode, ttl, cache, age, rule?.rule),
    payload,
    renderMs,
    servedAt: at,
  };
}

/** A browser-side fetch (useFetch with server: false). Never touches the page cache. */
export function clientFetch<T>(store: NewsStore, url: string): T | null {
  const d = db(store);
  const r = callApi<T>(url, d);
  store.breakingCalls = d.breakingCalls;
  store.write(`GET ${url} (browser, cache-control: no-store) → ${r.status}`, "muted");
  return r.status === 200 ? r.body : null;
}
app/lab/nuxt-news-site/app/server/api.ts174 lines
/**
 * The server API routes. In the Nuxt app each handler is its own file under server/api/
 * (the `file` field) wrapped in defineEventHandler; here they share one module so the demo
 * can call them in-process. Pages reach them through $fetch, exactly like useFetch does on the server.
 */
import { compilePath, parseUrl, type Query } from "../utils/url";
import { AUTHORS, BREAKING, CATEGORIES, type Article, type Author, type CategorySlug } from "./data";

export interface Db {
  articles: Article[];
  breakingCalls: number;
}

/** What list views receive: no body, author resolved. */
export interface ArticleCard {
  slug: string;
  title: string;
  standfirst: string;
  category: CategorySlug;
  categoryName: string;
  hero: string;
  author: Author;
  publishedAt: number;
  updatedAt: number | null;
}

export interface FrontPageData {
  lead: ArticleCard | null;
  top: ArticleCard[];
  sections: { slug: CategorySlug; name: string; items: ArticleCard[] }[];
}

export interface CategoryData {
  category: { slug: CategorySlug; name: string; blurb: string };
  items: ArticleCard[];
  page: number;
  pages: number;
  total: number;
}

export interface ArticleData {
  article: ArticleCard & { body: string[] };
  related: ArticleCard[];
}

export interface SearchData {
  q: string;
  results: ArticleCard[];
  total: number;
}

export class ApiError extends Error {
  constructor(
    public statusCode: number,
    message: string,
  ) {
    super(message);
  }
}

export const PAGE_SIZE = 4;

export function toCard(a: Article): ArticleCard {
  const cat = CATEGORIES.find((c) => c.slug === a.category)!;
  return {
    slug: a.slug,
    title: a.title,
    standfirst: a.standfirst,
    category: a.category,
    categoryName: cat.name,
    hero: cat.hero,
    author: AUTHORS.find((x) => x.id === a.authorId) ?? AUTHORS[0]!,
    publishedAt: a.publishedAt,
    updatedAt: a.updatedAt,
  };
}

const newest = (db: Db) => [...db.articles].sort((a, b) => b.publishedAt - a.publishedAt);

type Handler = (params: Record<string, string>, query: Query, db: Db) => unknown;

/** server/api/front-page.get.ts */
const frontPage: Handler = (_p, _q, db): FrontPageData => {
  const all = newest(db);
  return {
    lead: all[0] ? toCard(all[0]) : null,
    top: all.slice(1, 7).map(toCard),
    sections: CATEGORIES.map((c) => ({
      slug: c.slug,
      name: c.name,
      items: all.filter((a) => a.category === c.slug).slice(0, 3).map(toCard),
    })),
  };
};

/** server/api/categories/[slug].get.ts */
const categoryPage: Handler = (params, query, db): CategoryData => {
  const cat = CATEGORIES.find((c) => c.slug === params.slug);
  if (!cat) throw new ApiError(404, `Category "${params.slug}" not found`);
  const all = newest(db).filter((a) => a.category === cat.slug);
  const pages = Math.max(1, Math.ceil(all.length / PAGE_SIZE));
  const page = Number(query.page ?? 1);
  if (!Number.isInteger(page) || page < 1 || page > pages) throw new ApiError(404, `Page ${query.page} does not exist`);
  return {
    category: { slug: cat.slug, name: cat.name, blurb: cat.blurb },
    items: all.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE).map(toCard),
    page,
    pages,
    total: all.length,
  };
};

/** server/api/articles/[slug].get.ts */
const articlePage: Handler = (params, _q, db): ArticleData => {
  const a = db.articles.find((x) => x.slug === params.slug);
  if (!a) throw new ApiError(404, `Article "${params.slug}" not found`);
  return {
    article: { ...toCard(a), body: [...a.body] },
    related: newest(db)
      .filter((x) => x.category === a.category && x.slug !== a.slug)
      .slice(0, 3)
      .map(toCard),
  };
};

/** server/api/search.get.ts: every term must match headline, standfirst, section or author. */
const search: Handler = (_p, query, db): SearchData => {
  const q = (query.q ?? "").slice(0, 80);
  const terms = q.toLowerCase().split(/\s+/).filter((t) => t.length > 1);
  if (!terms.length) return { q, results: [], total: 0 };
  const scored = newest(db)
    .map(toCard)
    .map((c) => {
      const title = c.title.toLowerCase();
      const hay = `${title} ${c.standfirst} ${c.categoryName} ${c.author.name}`.toLowerCase();
      if (!terms.every((t) => hay.includes(t))) return null;
      return { c, score: terms.filter((t) => title.includes(t)).length };
    })
    .filter((x): x is { c: ArticleCard; score: number } => x !== null)
    .sort((a, b) => b.score - a.score || b.c.publishedAt - a.c.publishedAt);
  return { q, results: scored.slice(0, 20).map((x) => x.c), total: scored.length };
};

/** server/api/breaking.get.ts: fetched from the browser only, never part of cached HTML. */
const breaking: Handler = (_p, _q, db) => ({
  headline: BREAKING[db.breakingCalls++ % BREAKING.length]!,
});

export const apiRoutes: { path: string; file: string; handler: Handler }[] = [
  { path: "/api/front-page", file: "server/api/front-page.get.ts", handler: frontPage },
  { path: "/api/categories/:slug", file: "server/api/categories/[slug].get.ts", handler: categoryPage },
  { path: "/api/articles/:slug", file: "server/api/articles/[slug].get.ts", handler: articlePage },
  { path: "/api/search", file: "server/api/search.get.ts", handler: search },
  { path: "/api/breaking", file: "server/api/breaking.get.ts", handler: breaking },
];

const matchers = apiRoutes.map((r) => ({ ...r, match: compilePath(r.path) }));

/** Server-side $fetch: dispatches to the handler in-process (Nitro does the same, no HTTP hop). */
export function callApi<T>(raw: string, db: Db): { status: number; body: T; file: string } {
  const { path, query } = parseUrl(raw);
  for (const r of matchers) {
    const params = r.match(path);
    if (!params) continue;
    try {
      return { status: 200, body: r.handler(params, query, db) as T, file: r.file };
    } catch (e) {
      if (e instanceof ApiError) return { status: e.statusCode, body: { message: e.message } as T, file: r.file };
      throw e;
    }
  }
  return { status: 404, body: { message: "No API route" } as T, file: "(none)" };
}
app/lab/nuxt-news-site/app/composables.ts104 lines
/**
 * Small stand-ins for the Nuxt APIs the pages use:
 *   definePage    ~ a file in pages/ plus its useFetch call
 *   usePageData   ~ the data half of useFetch after hydration (read from the payload, no refetch)
 *   defineSeo     ~ useSeoMeta + useHead (title, meta, canonical, JSON-LD)
 *   NuxtLink      ~ <NuxtLink>: a real <a href> that navigates client-side
 */
import { computed, defineComponent, inject, type Component, type ComputedRef, type InjectionKey } from "vue";
import type { Query } from "./utils/url";
import { SITE } from "./server/data";

export interface SeoInput {
  title: string;
  description: string;
  path: string;
  type?: "website" | "article";
  image?: string;
  robots?: string;
  jsonLd?: Record<string, unknown>;
}

export interface HeadOutput {
  title: string;
  meta: { key: "name" | "property"; name: string; content: string }[];
  link: { rel: string; href: string }[];
  jsonLd: Record<string, unknown> | null;
}

/** What the server serialises into the HTML (window.__NUXT__ in Nuxt) and the cache stores. */
export interface Payload {
  page: string;
  status: number;
  params: Record<string, string>;
  query: Query;
  data: unknown;
  head: HeadOutput;
  renderedAt: number;
}

export interface PageContext {
  params: Record<string, string>;
  query: Query;
  /** Server-side $fetch. Returns null and sets a 404 when the API route does. */
  $fetch: <T>(url: string) => T | null;
}

export interface PageDef<T> {
  name: string;
  /** Route pattern, and the file it would be in a Nuxt app. */
  path: string;
  file: string;
  /** Runs on the server during render (useFetch). Return null for a 404. */
  data(ctx: PageContext): T | null;
  head(data: T, ctx: PageContext): SeoInput;
  component: Component;
}

export const definePage = <T>(def: PageDef<T>) => def;

export const PAYLOAD: InjectionKey<ComputedRef<Payload | null>> = Symbol("payload");
export const NAVIGATE: InjectionKey<(to: string) => void> = Symbol("navigate");

export function usePageData<T>() {
  const payload = inject(PAYLOAD, computed(() => null));
  return {
    data: computed(() => (payload.value?.data ?? null) as T | null),
    query: computed(() => payload.value?.query ?? {}),
  };
}

export function defineSeo(input: SeoInput): HeadOutput {
  const url = SITE.origin + input.path;
  const title = `${input.title} | ${SITE.name}`;
  const image = input.image ?? `${SITE.origin}/og${input.path === "/" ? "/home" : input.path}.png`;
  const meta: HeadOutput["meta"] = [
    { key: "name", name: "description", content: input.description },
    { key: "property", name: "og:type", content: input.type ?? "website" },
    { key: "property", name: "og:title", content: input.title },
    { key: "property", name: "og:description", content: input.description },
    { key: "property", name: "og:url", content: url },
    { key: "property", name: "og:image", content: image },
    { key: "property", name: "og:site_name", content: SITE.name },
    { key: "name", name: "twitter:card", content: "summary_large_image" },
  ];
  if (input.robots) meta.unshift({ key: "name", name: "robots", content: input.robots });
  return { title, meta, link: [{ rel: "canonical", href: url }], jsonLd: input.jsonLd ?? null };
}

export const NuxtLink = defineComponent({
  name: "NuxtLink",
  props: { to: { type: String, required: true } },
  setup(props) {
    const navigate = inject(NAVIGATE, () => undefined);
    function onClick(e: MouseEvent) {
      // Let the browser handle new-tab and modified clicks, like NuxtLink does.
      if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
      e.preventDefault();
      navigate(props.to);
    }
    return { onClick, href: computed(() => "#" + props.to) };
  },
  template: `<a :href="href" @click="onClick"><slot /></a>`,
});
app/lab/nuxt-news-site/app/router.ts31 lines
/**
 * The page router. Nuxt generates this table from the pages/ directory; here it is written out.
 */
import type { PageDef } from "./composables";
import article from "./pages/article";
import category from "./pages/category";
import index from "./pages/index";
import notFound from "./pages/not-found";
import saved from "./pages/saved";
import search from "./pages/search";
import { compilePath } from "./utils/url";

export type AnyPage = PageDef<unknown>;

export const pages: AnyPage[] = [index, category, article, search, saved] as AnyPage[];
export const errorPage = notFound as AnyPage;

const table = pages.map((page) => ({ page, match: compilePath(page.path) }));

export function matchPage(path: string): { page: AnyPage; params: Record<string, string> } | null {
  for (const { page, match } of table) {
    const params = match(path);
    if (params) return { page, params };
  }
  return null;
}

export function pageByName(name: string): AnyPage {
  return pages.find((p) => p.name === name) ?? errorPage;
}
app/lab/nuxt-news-site/app/pages/article.ts89 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables called in setup(), not React hooks. */
import { computed, defineComponent } from "vue";
import StoryCard from "../components/StoryCard";
import { NuxtLink, definePage, usePageData } from "../composables";
import type { ArticleData } from "../server/api";
import { SITE } from "../server/data";
import { useNewsStore } from "../stores/news";
import { dateTime, iso, timeAgo } from "../utils/format";

/** pages/article/[slug].vue: full text in the server HTML, NewsArticle JSON-LD in the head. */
export default definePage<ArticleData>({
  name: "article-slug",
  path: "/article/:slug",
  file: "pages/article/[slug].vue",
  data: ({ params, $fetch }) => $fetch<ArticleData>(`/api/articles/${params.slug}`),
  head: ({ article: a }) => {
    const path = `/article/${a.slug}`;
    const image = `${SITE.origin}/images/${a.slug}-1200x675.jpg`;
    return {
      title: a.title,
      description: a.standfirst,
      path,
      type: "article",
      image,
      jsonLd: {
        "@context": "https://schema.org",
        "@type": "NewsArticle",
        headline: a.title,
        description: a.standfirst,
        image: [image],
        datePublished: iso(a.publishedAt),
        dateModified: iso(a.updatedAt ?? a.publishedAt),
        articleSection: a.categoryName,
        author: [{ "@type": "Person", name: a.author.name, url: `${SITE.origin}/author/${a.author.id}` }],
        publisher: { "@type": "Organization", name: SITE.name, logo: { "@type": "ImageObject", url: SITE.logo } },
        mainEntityOfPage: SITE.origin + path,
      },
    };
  },
  component: defineComponent({
    name: "ArticlePage",
    components: { NuxtLink, StoryCard },
    setup() {
      const { data } = usePageData<ArticleData>();
      const store = useNewsStore();
      const saved = computed(() => !!data.value && store.bookmarks.includes(data.value.article.slug));
      return { data, store, saved, iso, dateTime, timeAgo };
    },
    template: `
      <article v-if="data" class="mx-auto max-w-2xl">
        <p class="font-mono text-xs text-fg-muted">
          <NuxtLink to="/" class="hover:text-accent">Home</NuxtLink> /
          <NuxtLink :to="'/category/' + data.article.category" class="hover:text-accent">{{ data.article.categoryName }}</NuxtLink>
        </p>
        <h2 class="mt-2 text-2xl font-bold leading-tight sm:text-3xl">{{ data.article.title }}</h2>
        <p class="mt-2 text-lg text-fg-muted">{{ data.article.standfirst }}</p>
        <div class="mt-3 flex flex-wrap items-center justify-between gap-2 border-y border-border py-2 text-xs text-fg-muted">
          <p>
            By <span class="font-medium text-fg">{{ data.article.author.name }}</span>, {{ data.article.author.role }} ·
            <time :datetime="iso(data.article.publishedAt)">{{ dateTime(data.article.publishedAt) }}</time>
            <template v-if="data.article.updatedAt">
              · <span class="text-warn-fg">Updated <time :datetime="iso(data.article.updatedAt)">{{ timeAgo(data.article.updatedAt, store.now) }}</time></span>
            </template>
          </p>
          <button
            type="button"
            :aria-pressed="saved"
            class="rounded border border-border px-2 py-0.5 font-mono hover:border-accent hover:text-accent"
            @click="store.toggleBookmark(data.article.slug)"
          >{{ saved ? "★ Saved" : "☆ Save" }}</button>
        </div>
        <figure class="mt-4">
          <div :class="['aspect-[16/9] rounded-md bg-linear-to-br', data.article.hero]" role="img" :aria-label="'Placeholder illustration for ' + data.article.title"></div>
          <figcaption class="mt-1 text-xs text-fg-muted">Illustration placeholder. Acme Daily is a fictional publication.</figcaption>
        </figure>
        <div class="mt-4 space-y-3 leading-relaxed">
          <p v-for="(para, i) in data.article.body" :key="i">{{ para }}</p>
        </div>
        <section v-if="data.related.length" aria-labelledby="related-h" class="mt-8">
          <h3 id="related-h" class="border-b border-border pb-1 font-mono text-sm font-semibold uppercase tracking-wide">More in {{ data.article.categoryName }}</h3>
          <ul class="mt-3 space-y-3">
            <li v-for="s in data.related" :key="s.slug"><StoryCard :story="s" compact /></li>
          </ul>
        </section>
      </article>
    `,
  }),
});
app/lab/nuxt-news-site/app/pages/index.ts79 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables called in setup(), not React hooks. */
import { defineComponent } from "vue";
import StoryCard from "../components/StoryCard";
import { NuxtLink, definePage, usePageData } from "../composables";
import type { FrontPageData } from "../server/api";
import { SITE } from "../server/data";
import { useNewsStore } from "../stores/news";
import { iso, timeAgo } from "../utils/format";

/** pages/index.vue: front page with lead story, top stories and one block per section. */
export default definePage<FrontPageData>({
  name: "index",
  path: "/",
  file: "pages/index.vue",
  data: ({ $fetch }) => $fetch<FrontPageData>("/api/front-page"),
  head: () => ({
    title: "Latest news",
    description: "Front page of Acme Daily, a fictional demo publication: the lead story, top stories and the latest from every section.",
    path: "/",
    jsonLd: {
      "@context": "https://schema.org",
      "@type": "WebSite",
      name: SITE.name,
      url: SITE.origin,
      potentialAction: {
        "@type": "SearchAction",
        target: `${SITE.origin}/search?q={search_term_string}`,
        "query-input": "required name=search_term_string",
      },
    },
  }),
  component: defineComponent({
    name: "IndexPage",
    components: { NuxtLink, StoryCard },
    setup() {
      const { data } = usePageData<FrontPageData>();
      const store = useNewsStore();
      return { data, store, timeAgo, iso };
    },
    template: `
      <div v-if="data" class="space-y-8">
        <article v-if="data.lead" class="grid gap-4 md:grid-cols-[1.2fr_1fr]">
          <div :class="['aspect-[16/9] rounded-md bg-linear-to-br', data.lead.hero]" role="img" :aria-label="'Placeholder illustration for ' + data.lead.title"></div>
          <div>
            <p class="font-mono text-xs uppercase tracking-wide text-accent">{{ data.lead.categoryName }} · lead story</p>
            <h2 class="mt-1 text-2xl font-bold leading-tight">
              <NuxtLink :to="'/article/' + data.lead.slug" class="hover:text-accent">{{ data.lead.title }}</NuxtLink>
            </h2>
            <p class="mt-2 text-fg-muted">{{ data.lead.standfirst }}</p>
            <p class="mt-2 text-xs text-fg-muted">
              By {{ data.lead.author.name }} ·
              <time :datetime="iso(data.lead.publishedAt)">{{ timeAgo(data.lead.publishedAt, store.now) }}</time>
            </p>
          </div>
        </article>

        <section aria-labelledby="top-stories">
          <h2 id="top-stories" class="border-b border-border pb-1 font-mono text-sm font-semibold uppercase tracking-wide">Top stories</h2>
          <ul class="mt-3 grid gap-4 sm:grid-cols-2">
            <li v-for="s in data.top" :key="s.slug"><StoryCard :story="s" /></li>
          </ul>
        </section>

        <div class="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
          <section v-for="sec in data.sections" :key="sec.slug" :aria-labelledby="'sec-' + sec.slug">
            <h2 :id="'sec-' + sec.slug" class="flex items-baseline justify-between border-b border-border pb-1 font-mono text-sm font-semibold uppercase tracking-wide">
              {{ sec.name }}
              <NuxtLink :to="'/category/' + sec.slug" class="text-xs font-normal normal-case text-accent hover:underline">More {{ sec.name }} →</NuxtLink>
            </h2>
            <ul class="mt-3 space-y-3">
              <li v-for="s in sec.items" :key="s.slug"><StoryCard :story="s" compact /></li>
            </ul>
          </section>
        </div>
      </div>
    `,
  }),
});
app/lab/nuxt-news-site/app/pages/category.ts61 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables called in setup(), not React hooks. */
import { computed, defineComponent } from "vue";
import StoryCard from "../components/StoryCard";
import { NuxtLink, definePage, usePageData } from "../composables";
import type { CategoryData } from "../server/api";
import { SITE } from "../server/data";

/** pages/category/[slug].vue: one section, paginated with real links (?page=2). */
export default definePage<CategoryData>({
  name: "category-slug",
  path: "/category/:slug",
  file: "pages/category/[slug].vue",
  data: ({ params, query, $fetch }) =>
    $fetch<CategoryData>(`/api/categories/${params.slug}${query.page ? `?page=${query.page}` : ""}`),
  head: (d) => {
    const path = `/category/${d.category.slug}${d.page > 1 ? `?page=${d.page}` : ""}`;
    return {
      title: d.page > 1 ? `${d.category.name} news, page ${d.page}` : `${d.category.name} news`,
      description: `${d.category.blurb} Latest ${d.category.name.toLowerCase()} stories from Acme Daily, a fictional demo publication.`,
      path,
      jsonLd: {
        "@context": "https://schema.org",
        "@type": "CollectionPage",
        name: `${d.category.name} news`,
        url: SITE.origin + path,
        isPartOf: { "@type": "WebSite", name: SITE.name, url: SITE.origin },
      },
    };
  },
  component: defineComponent({
    name: "CategoryPage",
    components: { NuxtLink, StoryCard },
    setup() {
      const { data } = usePageData<CategoryData>();
      const pageHref = (n: number) =>
        `/category/${data.value?.category.slug ?? ""}${n > 1 ? `?page=${n}` : ""}`;
      const pages = computed(() => Array.from({ length: data.value?.pages ?? 0 }, (_, i) => i + 1));
      return { data, pageHref, pages };
    },
    template: `
      <div v-if="data">
        <p class="font-mono text-xs text-fg-muted"><NuxtLink to="/" class="hover:text-accent">Home</NuxtLink> / {{ data.category.name }}</p>
        <h2 class="mt-1 text-2xl font-bold">{{ data.category.name }}</h2>
        <p class="text-sm text-fg-muted">{{ data.category.blurb }} {{ data.total }} stories.</p>
        <ul class="mt-5 space-y-5">
          <li v-for="s in data.items" :key="s.slug" class="border-b border-border pb-4 last:border-0"><StoryCard :story="s" /></li>
        </ul>
        <nav v-if="data.pages > 1" aria-label="Pagination" class="mt-4 flex flex-wrap items-center gap-2 font-mono text-sm">
          <NuxtLink v-if="data.page > 1" :to="pageHref(data.page - 1)" rel="prev" class="rounded border border-border px-2.5 py-1 hover:border-accent hover:text-accent">← Newer</NuxtLink>
          <NuxtLink
            v-for="n in pages" :key="n" :to="pageHref(n)"
            :aria-current="n === data.page ? 'page' : undefined"
            :class="['rounded border px-2.5 py-1', n === data.page ? 'border-accent bg-accent text-accent-fg' : 'border-border hover:border-accent hover:text-accent']"
          >{{ n }}</NuxtLink>
          <NuxtLink v-if="data.page < data.pages" :to="pageHref(data.page + 1)" rel="next" class="rounded border border-border px-2.5 py-1 hover:border-accent hover:text-accent">Older →</NuxtLink>
        </nav>
      </div>
    `,
  }),
});
app/lab/nuxt-news-site/app/pages/search.ts52 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables called in setup(), not React hooks. */
import { defineComponent, inject, ref, watch } from "vue";
import StoryCard from "../components/StoryCard";
import { NAVIGATE, definePage, usePageData } from "../composables";
import type { SearchData } from "../server/api";

/** pages/search.vue: the query lives in the URL and results are rendered on the server, never cached. */
export default definePage<SearchData>({
  name: "search",
  path: "/search",
  file: "pages/search.vue",
  data: ({ query, $fetch }) => $fetch<SearchData>(`/api/search?q=${encodeURIComponent(query.q ?? "")}`),
  head: (d) => ({
    title: d.q ? `Search: ${d.q}` : "Search",
    description: "Search Acme Daily, a fictional demo publication, by headline, section or author.",
    path: d.q ? `/search?q=${encodeURIComponent(d.q)}` : "/search",
    // Search result pages are thin and endless: keep them out of the index, but follow links.
    robots: "noindex, follow",
  }),
  component: defineComponent({
    name: "SearchPage",
    components: { StoryCard },
    setup() {
      const { data } = usePageData<SearchData>();
      const navigate = inject(NAVIGATE, () => undefined);
      const term = ref(data.value?.q ?? "");
      watch(data, (d) => (term.value = d?.q ?? ""));
      const submit = () => navigate(term.value.trim() ? `/search?q=${encodeURIComponent(term.value.trim())}` : "/search");
      return { data, term, submit };
    },
    template: `
      <div v-if="data" class="mx-auto max-w-2xl">
        <h2 class="text-2xl font-bold">Search</h2>
        <form role="search" class="mt-3 flex gap-2" @submit.prevent="submit">
          <label class="sr-only" for="news-search-q">Search articles</label>
          <input id="news-search-q" v-model="term" type="search" placeholder="e.g. solar, festival, rail"
            class="w-full rounded-md border border-border bg-bg-elev px-3 py-2 text-sm outline-none focus:border-accent" />
          <button type="submit" class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:opacity-90">Search</button>
        </form>
        <p class="mt-3 text-sm text-fg-muted" aria-live="polite">
          <template v-if="!data.q">Type at least two letters. Try “open data” or “festival”.</template>
          <template v-else-if="data.total === 0">No stories match “{{ data.q }}”.</template>
          <template v-else>{{ data.total }} {{ data.total === 1 ? "story matches" : "stories match" }} “{{ data.q }}”.</template>
        </p>
        <ul class="mt-4 space-y-5">
          <li v-for="s in data.results" :key="s.slug" class="border-b border-border pb-4 last:border-0"><StoryCard :story="s" :q="data.q" /></li>
        </ul>
      </div>
    `,
  }),
});
app/lab/nuxt-news-site/app/pages/saved.ts54 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables called in setup(), not React hooks. */
import { computed, defineComponent } from "vue";
import StoryCard from "../components/StoryCard";
import { NuxtLink, definePage } from "../composables";
import { toCard } from "../server/api";
import { useNewsStore } from "../stores/news";

/**
 * pages/saved.vue with routeRules { ssr: false }: bookmarks live in the reader's browser,
 * so the server only sends the app shell and this page renders client-side.
 */
export default definePage<Record<string, never>>({
  name: "saved",
  path: "/saved",
  file: "pages/saved.vue",
  data: () => ({}),
  head: () => ({
    title: "Saved articles",
    description: "Articles you saved on this device. Stored in your browser only.",
    path: "/saved",
    robots: "noindex, nofollow",
  }),
  component: defineComponent({
    name: "SavedPage",
    components: { NuxtLink, StoryCard },
    setup() {
      const store = useNewsStore();
      const items = computed(() =>
        store.bookmarks
          .map((slug) => store.articles.find((a) => a.slug === slug))
          .filter((a) => a !== undefined)
          .map(toCard),
      );
      return { store, items };
    },
    template: `
      <div class="mx-auto max-w-2xl">
        <h2 class="text-2xl font-bold">Saved articles</h2>
        <p class="text-sm text-fg-muted">Kept in this browser only. This route is client-rendered (<code class="font-mono">ssr: false</code>).</p>
        <p v-if="!items.length" class="mt-6 rounded-md border border-dashed border-border p-6 text-center text-sm text-fg-muted">
          Nothing saved yet. Open any article and press <span class="font-mono">☆ Save</span>.
        </p>
        <ul v-else class="mt-5 space-y-5">
          <li v-for="s in items" :key="s.slug" class="flex items-start justify-between gap-3 border-b border-border pb-4 last:border-0">
            <StoryCard :story="s" />
            <button type="button" class="shrink-0 rounded border border-border px-2 py-0.5 font-mono text-xs hover:border-danger hover:text-danger"
              :aria-label="'Remove ' + s.title + ' from saved'" @click="store.toggleBookmark(s.slug)">Remove</button>
          </li>
        </ul>
      </div>
    `,
  }),
});
app/lab/nuxt-news-site/app/stores/news.ts173 lines
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { clearState, loadState, persistTo } from "@/components/lab/vue/persist";
import type { Payload } from "../composables";
import { makeArticle, RESERVE, seedArticles, slugify, type Article } from "../server/data";
import type { CacheEntry, CacheStatus, RenderMode } from "../server/nitro";

const KEY = "lab:nuxt-news-site:v1";

export type Tone = "info" | "ok" | "warn" | "err" | "muted" | "bg";
export interface LogLine {
  id: number;
  at: number;
  text: string;
  tone: Tone;
}

/** One served response: what the browser received for the current URL. */
export interface ServedResponse {
  url: string;
  status: number;
  mode: RenderMode;
  ttl: number | null;
  rulePattern: string | null;
  ruleJson: string;
  cache: CacheStatus;
  headers: [string, string][];
  payload: Payload;
  renderMs: number;
  servedAt: number;
}

interface Saved {
  baseTime: number;
  clockOffset: number;
  articles: Article[];
  cache: Record<string, CacheEntry<Payload>>;
  bookmarks: string[];
  reserveIndex: number;
}

/**
 * Everything the simulated server and the reader share: the CMS (articles), the page cache,
 * a fast-forwardable clock and the reader's bookmarks. Persisted to localStorage.
 */
export const useNewsStore = defineStore("news", () => {
  const saved = loadState<Saved>(KEY);
  const baseTime = ref(saved?.baseTime ?? Date.now());
  const clockOffset = ref(saved?.clockOffset ?? 0);
  const articles = ref<Article[]>(saved?.articles ?? seedArticles(baseTime.value));
  const cache = ref<Record<string, CacheEntry<Payload>>>(saved?.cache ?? {});
  const bookmarks = ref<string[]>(saved?.bookmarks ?? []);
  const reserveIndex = ref(saved?.reserveIndex ?? 0);
  const breakingCalls = ref(0);

  const wall = ref(Date.now());
  /** Simulated server clock (real time plus fast-forward), ticking once a second for the UI. */
  const now = computed(() => wall.value + clockOffset.value);
  const nowMs = () => Date.now() + clockOffset.value;

  const log = ref<LogLine[]>([]);
  const response = ref<ServedResponse | null>(null);
  const loading = ref(false);
  const history = ref<string[]>([]);
  const historyIndex = ref(-1);
  let seq = 0;

  persistTo(KEY, () => ({
    baseTime: baseTime.value,
    clockOffset: clockOffset.value,
    articles: articles.value,
    cache: cache.value,
    bookmarks: bookmarks.value,
    reserveIndex: reserveIndex.value,
  }));

  function tick() {
    wall.value = Date.now();
  }

  function write(text: string, tone: Tone = "info") {
    log.value = [...log.value, { id: ++seq, at: nowMs(), text, tone }].slice(-80);
  }

  function advance(seconds: number) {
    clockOffset.value += seconds * 1000;
    tick();
    write(`clock fast-forwarded ${seconds >= 3600 ? seconds / 3600 + "h" : seconds >= 60 ? seconds / 60 + "m" : seconds + "s"}`, "muted");
  }

  /** CMS: publish the next reserve story. Cached pages keep the old list until they revalidate. */
  function publishStory() {
    const seed = RESERVE[reserveIndex.value % RESERVE.length]!;
    reserveIndex.value++;
    const a = makeArticle(seed, reserveIndex.value, nowMs());
    if (articles.value.some((x) => x.slug === a.slug)) a.slug = slugify(`${a.title} ${reserveIndex.value}`);
    articles.value = [a, ...articles.value];
    write(`cms: published /article/${a.slug}`, "warn");
    return a;
  }

  /** CMS: edit an article's headline. The ISR copy stays stale until it is regenerated. */
  function correctArticle(slug: string) {
    const a = articles.value.find((x) => x.slug === slug);
    if (!a) return;
    a.title = a.title.replace(/ \(updated\)$/, "") + " (updated)";
    a.standfirst = a.standfirst.startsWith("Update:") ? a.standfirst : `Update: ${a.standfirst}`;
    a.updatedAt = nowMs();
    write(`cms: edited /article/${slug} (headline and standfirst)`, "warn");
  }

  /** On-demand revalidation: drop the cached copy so the next request renders fresh. */
  function purge(url: string) {
    if (cache.value[url]) {
      const next = { ...cache.value };
      delete next[url];
      cache.value = next;
      write(`purge ${url}: cached copy removed`, "warn");
    } else {
      write(`purge ${url}: nothing cached`, "muted");
    }
  }

  function toggleBookmark(slug: string) {
    bookmarks.value = bookmarks.value.includes(slug)
      ? bookmarks.value.filter((s) => s !== slug)
      : [slug, ...bookmarks.value];
  }

  function reset() {
    clearState(KEY);
    baseTime.value = Date.now();
    clockOffset.value = 0;
    articles.value = seedArticles(baseTime.value);
    cache.value = {};
    bookmarks.value = [];
    reserveIndex.value = 0;
    breakingCalls.value = 0;
    log.value = [];
    history.value = [];
    historyIndex.value = -1;
    response.value = null;
    tick();
  }

  return {
    baseTime,
    clockOffset,
    articles,
    cache,
    bookmarks,
    reserveIndex,
    breakingCalls,
    now,
    nowMs,
    log,
    response,
    loading,
    history,
    historyIndex,
    tick,
    write,
    advance,
    publishStory,
    correctArticle,
    purge,
    toggleBookmark,
    reset,
  };
});

export type NewsStore = ReturnType<typeof useNewsStore>;
app/lab/nuxt-news-site/app/components/RenderPanel.ts110 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables called in setup(), not React hooks. */
import { computed, defineComponent } from "vue";
import type { ArticleData } from "../server/api";
import { routeRules } from "../server/nitro";
import { pageByName } from "../router";
import { useNewsStore } from "../stores/news";
import { clockTime, duration } from "../utils/format";

/** Dev panel: which route rule matched, how the page was produced and how fresh the cached copy is. */
export default defineComponent({
  name: "RenderPanel",
  emits: ["reload"],
  setup() {
    const store = useNewsStore();
    const res = computed(() => store.response);
    const entry = computed(() => (res.value ? store.cache[res.value.url] : undefined));
    const age = computed(() => (entry.value ? (store.now - entry.value.renderedAt) / 1000 : null));
    const freshness = computed(() => {
      const r = res.value;
      if (!r) return "";
      if (r.status !== 200) return "Errors are not cached; every request renders again.";
      if (r.mode === "spa") return "Not rendered on the server. Each visit gets the same app shell.";
      if (r.ttl === null) return "Rendered on every request. Nothing is stored.";
      if (age.value === null) return "No cached copy (purged). The next request is a MISS and renders.";
      const left = r.ttl - age.value;
      return left > 0
        ? `Fresh for another ${duration(left)}. Requests now are HITs.`
        : `Stale for ${duration(-left)}. The next request gets this copy (STALE) and triggers a background regenerate.`;
    });
    /** Has the CMS changed since the copy being served was rendered? */
    const behindCms = computed(() => {
      const r = res.value;
      if (!r || r.status !== 200 || r.mode === "spa" || r.mode === "ssr") return false;
      const rendered = entry.value?.renderedAt ?? r.payload.renderedAt;
      const changed = (a: { publishedAt: number; updatedAt: number | null }) => Math.max(a.publishedAt, a.updatedAt ?? 0) > rendered;
      if (r.payload.page === "article-slug") {
        const slug = (r.payload.data as ArticleData).article.slug;
        const a = store.articles.find((x) => x.slug === slug);
        return !!a && changed(a);
      }
      return store.articles.some(changed);
    });
    const articleSlug = computed(() =>
      res.value?.payload.page === "article-slug" ? (res.value.payload.data as ArticleData).article.slug : null,
    );
    const file = computed(() => (res.value ? pageByName(res.value.payload.page).file : ""));
    const rules = Object.entries(routeRules).map(([k, v]) => `"${k}": ${JSON.stringify(v)}`);
    const badge: Record<string, string> = {
      HIT: "bg-accent text-accent-fg",
      STALE: "bg-warn-bg text-warn-fg border border-warn-fg/40",
      MISS: "bg-accent-2 text-bg",
      BYPASS: "bg-bg-muted text-fg-muted border border-border",
    };
    return { store, res, age, freshness, behindCms, articleSlug, file, rules, badge, clockTime };
  },
  template: `
    <div v-if="res" class="space-y-4 text-xs">
      <dl class="grid grid-cols-[6.5rem_1fr] gap-x-2 gap-y-1.5">
        <dt class="text-fg-muted">URL</dt><dd class="break-all font-mono">{{ res.url }}</dd>
        <dt class="text-fg-muted">Page file</dt><dd class="font-mono">{{ file }}</dd>
        <dt class="text-fg-muted">Route rule</dt>
        <dd class="font-mono"><span class="text-accent">{{ res.rulePattern ? '"' + res.rulePattern + '"' : "none" }}</span> {{ res.ruleJson }}</dd>
        <dt class="text-fg-muted">Mode</dt>
        <dd class="font-mono uppercase">{{ res.mode }}<span v-if="res.ttl"> · {{ res.ttl }}s</span></dd>
        <dt class="text-fg-muted">Cache</dt>
        <dd><span :class="['rounded px-1.5 py-0.5 font-mono font-semibold', badge[res.cache]]">{{ res.cache }}</span>
          <span class="ml-1 text-fg-muted">{{ res.cache === "HIT" || res.cache === "STALE" ? "served from cache" : res.mode === "spa" ? "shell only" : "rendered in " + res.renderMs + "ms" }}</span></dd>
        <dt class="text-fg-muted">Status</dt><dd class="font-mono" :class="res.status === 200 ? '' : 'text-danger'">{{ res.status }}</dd>
      </dl>

      <p class="rounded-md border border-border bg-bg p-2 leading-relaxed" aria-live="polite">{{ freshness }}</p>
      <p v-if="behindCms" class="rounded-md border border-warn-fg/40 bg-warn-bg p-2 leading-relaxed text-warn-fg">
        The CMS changed after this copy was rendered, so readers see the old version until it revalidates. Fast-forward past the TTL and reload, or purge.
      </p>

      <div>
        <h4 class="font-mono font-semibold">Response headers</h4>
        <ul class="mt-1 space-y-0.5 font-mono">
          <li v-for="[k, v] in res.headers" :key="k" class="break-all"><span class="text-fg-muted">{{ k }}:</span> {{ v }}</li>
        </ul>
      </div>

      <div>
        <h4 class="font-mono font-semibold">Server clock <span class="font-normal text-fg-muted">{{ clockTime(store.now) }}</span></h4>
        <div class="mt-1 flex flex-wrap gap-1.5">
          <button v-for="s in [30, 300, 3600]" :key="s" type="button" class="rounded border border-border px-2 py-1 font-mono hover:border-accent hover:text-accent" @click="store.advance(s)">
            +{{ s >= 3600 ? s / 3600 + "h" : s >= 60 ? s / 60 + "m" : s + "s" }}
          </button>
          <button type="button" class="rounded border border-border px-2 py-1 font-mono hover:border-accent hover:text-accent" @click="$emit('reload')">↻ Reload page</button>
        </div>
      </div>

      <div>
        <h4 class="font-mono font-semibold">Newsroom (CMS)</h4>
        <div class="mt-1 flex flex-wrap gap-1.5">
          <button type="button" class="rounded border border-border px-2 py-1 hover:border-accent hover:text-accent" @click="store.publishStory()">Publish a new story</button>
          <button v-if="articleSlug" type="button" class="rounded border border-border px-2 py-1 hover:border-accent hover:text-accent" @click="store.correctArticle(articleSlug)">Edit this headline</button>
          <button type="button" class="rounded border border-border px-2 py-1 hover:border-warn-fg hover:text-warn-fg" @click="store.purge(res.url)">Purge this URL</button>
        </div>
      </div>

      <details class="rounded-md border border-border">
        <summary class="cursor-pointer px-2 py-1.5 font-mono">nuxt.config.ts → routeRules</summary>
        <pre class="overflow-x-auto border-t border-border p-2 font-mono text-[11px]">{{ rules.join(",\\n") }}</pre>
      </details>
    </div>
    <p v-else class="text-xs text-fg-muted">Waiting for the first request…</p>
  `,
});
app/lab/nuxt-news-site/app/components/BreakingBanner.ts38 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables called in setup(), not React hooks. */
import { defineComponent, onMounted, onUnmounted, ref } from "vue";
import { clientFetch } from "../server/handler";
import { useNewsStore } from "../stores/news";

/**
 * components/BreakingBanner.client.vue: fetched in the browser (useFetch with server: false)
 * and refreshed on an interval, so a cached front page never shows an old breaking line.
 */
export default defineComponent({
  name: "BreakingBanner",
  setup() {
    const store = useNewsStore();
    const headline = ref<string | null>(null);
    const refresh = () => {
      headline.value = clientFetch<{ headline: string }>(store, "/api/breaking")?.headline ?? null;
    };
    let timer: ReturnType<typeof setInterval> | undefined;
    onMounted(() => {
      refresh();
      timer = setInterval(refresh, 30_000);
    });
    onUnmounted(() => clearInterval(timer));
    return { headline, refresh };
  },
  template: `
    <div class="flex items-center gap-2 border-b border-border bg-accent-soft px-4 py-1.5 text-xs">
      <span class="shrink-0 rounded bg-danger px-1.5 py-0.5 font-mono font-bold uppercase text-bg">Breaking</span>
      <p class="min-w-0 flex-1 truncate" role="status">
        <span v-if="headline">{{ headline }}</span>
        <span v-else class="text-fg-muted">Loading…</span>
      </p>
      <span class="hidden shrink-0 font-mono text-[10px] text-fg-muted sm:inline">client-only</span>
      <button type="button" class="shrink-0 rounded border border-border px-1.5 font-mono hover:border-accent hover:text-accent" aria-label="Refresh breaking news" @click="refresh">↻</button>
    </div>
  `,
});
app/lab/nuxt-news-site/app/App.ts180 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables called in setup(), not React hooks. */
import { computed, defineComponent, nextTick, onMounted, onUnmounted, provide, ref, watch } from "vue";
import BreakingBanner from "./components/BreakingBanner";
import HeadPanel from "./components/HeadPanel";
import RenderPanel from "./components/RenderPanel";
import { NAVIGATE, NuxtLink, PAYLOAD } from "./composables";
import { pageByName } from "./router";
import { CATEGORIES } from "./server/data";
import { handleRequest } from "./server/handler";
import type { ArticleData } from "./server/api";
import { useNewsStore } from "./stores/news";
import { clockTime } from "./utils/format";

/**
 * app.vue + layouts/default.vue, plus a dev sidebar that a real Nuxt site would not have:
 * the rendering panel, the generated <head> and the server request log.
 */
export default defineComponent({
  name: "NewsApp",
  components: { NuxtLink, BreakingBanner, RenderPanel, HeadPanel },
  setup() {
    const store = useNewsStore();
    const payload = computed(() => store.response?.payload ?? null);
    provide(PAYLOAD, payload);

    const addr = ref("/");
    const announce = ref("");
    const tab = ref<"render" | "head">("render");
    const viewport = ref<HTMLElement | null>(null);
    const logEl = ref<HTMLElement | null>(null);
    let latest = 0;

    async function go(to: string, push = true) {
      const id = ++latest;
      store.loading = true;
      try {
        const res = await handleRequest(store, to);
        if (id !== latest) return; // a newer navigation won
        store.response = res;
        addr.value = res.url;
        if (push && store.history[store.historyIndex] !== res.url) {
          store.history = [...store.history.slice(0, store.historyIndex + 1), res.url];
          store.historyIndex = store.history.length - 1;
        }
        announce.value = `Loaded ${res.url}: ${res.status}, cache ${res.cache}`;
        viewport.value?.scrollTo({ top: 0 });
      } finally {
        if (id === latest) store.loading = false;
      }
    }
    provide(NAVIGATE, (to: string) => void go(to));

    function back() {
      if (store.historyIndex <= 0) return;
      store.historyIndex--;
      void go(store.history[store.historyIndex]!, false);
    }
    function forward() {
      if (store.historyIndex >= store.history.length - 1) return;
      store.historyIndex++;
      void go(store.history[store.historyIndex]!, false);
    }
    const reload = () => store.response && void go(store.response.url, false);
    function reset() {
      store.reset();
      void go("/");
    }

    const pageComponent = computed(() => pageByName(payload.value?.page ?? "error").component);
    const activeCategory = computed(() => {
      const p = payload.value;
      if (!p) return null;
      if (p.page === "category-slug") return p.params.slug ?? null;
      if (p.page === "article-slug") return (p.data as ArticleData).article.category;
      return null;
    });

    watch(
      () => store.log.length,
      async () => {
        await nextTick();
        logEl.value?.scrollTo({ top: logEl.value.scrollHeight });
      },
    );

    let timer: ReturnType<typeof setInterval> | undefined;
    onMounted(() => {
      timer = setInterval(store.tick, 1000);
      const hash = window.location.hash;
      void go(hash.startsWith("#/") ? decodeURIComponent(hash.slice(1)) : "/");
    });
    onUnmounted(() => clearInterval(timer));

    const tone: Record<string, string> = {
      info: "text-[#e4e4e7]",
      ok: "text-[#4ade80]",
      warn: "text-[#fbbf24]",
      err: "text-[#f87171]",
      muted: "text-[#71717a]",
      bg: "text-[#60a5fa]",
    };

    return {
      store, addr, announce, tab, viewport, logEl, go, back, forward, reload, reset,
      pageComponent, activeCategory, categories: CATEGORIES, tone, clockTime,
    };
  },
  template: `
    <div class="text-sm">
      <p class="border-b border-border bg-warn-bg px-4 py-2 text-xs leading-relaxed text-warn-fg">
        This is Vue 3 + Pinia running client-side inside a Next.js page. It simulates the Nuxt 3 server (route rules,
        page cache, payload and head) so you can watch each step. The case study covers the real Nuxt deployment.
      </p>
      <div class="grid lg:grid-cols-[minmax(0,1fr)_23rem]">
        <div class="min-w-0 border-border lg:border-r">
          <div class="flex items-center gap-1 border-b border-border bg-bg-muted px-2 py-1.5">
            <button type="button" class="rounded px-2 py-1 font-mono hover:bg-bg disabled:opacity-40" aria-label="Back" :disabled="store.historyIndex <= 0" @click="back">←</button>
            <button type="button" class="rounded px-2 py-1 font-mono hover:bg-bg disabled:opacity-40" aria-label="Forward" :disabled="store.historyIndex >= store.history.length - 1" @click="forward">→</button>
            <button type="button" class="rounded px-2 py-1 font-mono hover:bg-bg" aria-label="Reload" @click="reload">↻</button>
            <form class="flex min-w-0 flex-1 items-center rounded-md border border-border bg-bg px-2 focus-within:border-accent" @submit.prevent="go(addr)">
              <span class="hidden font-mono text-xs text-fg-muted sm:inline" aria-hidden="true">news.example.com</span>
              <input id="news-addr" v-model="addr" aria-label="Address (path on news.example.com)" spellcheck="false" autocomplete="off" class="min-w-0 flex-1 bg-transparent py-1 font-mono text-xs outline-none" />
            </form>
          </div>

          <div ref="viewport" class="relative h-[36rem] overflow-y-auto lg:h-[46rem]">
            <div v-if="store.loading" class="sticky top-0 z-10 h-0.5 w-full animate-pulse bg-accent" aria-hidden="true"></div>
            <header class="border-b border-border px-4 pt-4 sm:px-6">
              <div class="flex items-center justify-between gap-3">
                <NuxtLink to="/" class="font-serif text-2xl font-black tracking-tight hover:text-accent">Acme Daily</NuxtLink>
                <div class="flex items-center gap-2 text-xs">
                  <NuxtLink to="/search" class="rounded border border-border px-2 py-1 hover:border-accent hover:text-accent">Search</NuxtLink>
                  <NuxtLink to="/saved" class="rounded border border-border px-2 py-1 hover:border-accent hover:text-accent">Saved ({{ store.bookmarks.length }})</NuxtLink>
                </div>
              </div>
              <nav aria-label="Sections" class="-mx-1 mt-3 overflow-x-auto whitespace-nowrap pb-2">
                <NuxtLink v-for="c in categories" :key="c.slug" :to="'/category/' + c.slug"
                  :aria-current="activeCategory === c.slug ? 'page' : undefined"
                  :class="['mx-1 inline-block rounded px-2 py-1 text-xs font-medium', activeCategory === c.slug ? 'bg-accent-soft text-accent' : 'text-fg-muted hover:text-fg']">{{ c.name }}</NuxtLink>
              </nav>
            </header>
            <BreakingBanner />
            <main class="px-4 py-5 sm:px-6">
              <component :is="pageComponent" :key="store.response ? store.response.url + store.response.payload.renderedAt : 'none'" />
            </main>
            <footer class="border-t border-border px-4 py-4 text-xs text-fg-muted sm:px-6">
              Acme Daily is a fictional publication. Every article, person and place on this site is demo content.
            </footer>
          </div>
        </div>

        <aside class="flex min-w-0 flex-col border-t border-border bg-bg-elev lg:border-t-0" aria-label="Nuxt rendering inspector">
          <div role="tablist" aria-label="Inspector" class="flex border-b border-border font-mono text-xs">
            <button id="tab-render" type="button" role="tab" :aria-selected="tab === 'render'" aria-controls="panel-render"
              :class="['flex-1 px-3 py-2', tab === 'render' ? 'border-b-2 border-accent text-accent' : 'text-fg-muted hover:text-fg']" @click="tab = 'render'">Rendering</button>
            <button id="tab-head" type="button" role="tab" :aria-selected="tab === 'head'" aria-controls="panel-head"
              :class="['flex-1 px-3 py-2', tab === 'head' ? 'border-b-2 border-accent text-accent' : 'text-fg-muted hover:text-fg']" @click="tab = 'head'">Generated &lt;head&gt;</button>
          </div>
          <div class="max-h-[30rem] overflow-y-auto p-4 lg:max-h-[27rem]">
            <div v-show="tab === 'render'" id="panel-render" role="tabpanel" aria-labelledby="tab-render"><RenderPanel @reload="reload" /></div>
            <div v-show="tab === 'head'" id="panel-head" role="tabpanel" aria-labelledby="tab-head"><HeadPanel /></div>
          </div>
          <div class="mt-auto border-t border-border p-3">
            <div class="flex items-center justify-between">
              <h3 class="font-mono text-xs font-semibold">server log</h3>
              <button type="button" class="rounded border border-border px-2 py-0.5 font-mono text-xs hover:border-danger hover:text-danger" @click="reset">Reset demo</button>
            </div>
            <ol ref="logEl" role="log" aria-live="polite" aria-label="Simulated server request log"
              class="mt-2 h-56 overflow-y-auto rounded-md border border-[#27272a] bg-[#0b0b0d] p-2 font-mono text-[11px] leading-relaxed">
              <li v-if="!store.log.length" class="text-[#71717a]">$ nuxi preview · waiting for requests…</li>
              <li v-for="l in store.log" :key="l.id" :class="['whitespace-pre-wrap break-all', tone[l.tone]]"><span class="text-[#52525b]">{{ clockTime(l.at) }} </span>{{ l.text }}</li>
            </ol>
          </div>
        </aside>
      </div>
      <p class="sr-only" aria-live="polite">{{ announce }}</p>
    </div>
  `,
});
components/lab/vue/VueIsland.tsx82 lines
"use client";

import { useEffect, useRef, useState } from "react";
import type { App, Component } from "vue";

type Props = {
  /** Stable loader for the Vue root component, e.g. `const load = () => import("./app/App")` at module scope. */
  load: () => Promise<{ default: Component }>;
  /** Accessible name for the region the Vue app renders into. */
  label: string;
  /** Reserve height while Vue loads so the page does not jump. */
  minHeight?: string;
};

/**
 * Mounts a Vue 3 + Pinia app inside a React (Next.js) page.
 *
 * React owns the outer <div>; Vue owns everything inside it. The full Vue build is imported
 * (not the runtime-only one) because the demo components use `template` strings, which need
 * the in-browser template compiler. Importing it once registers the compiler for every
 * component, so the components themselves import from plain "vue".
 */
export function VueIsland({ load, label, minHeight = "32rem" }: Props) {
  const host = useRef<HTMLDivElement>(null);
  const [state, setState] = useState<"loading" | "ready" | "error">("loading");

  useEffect(() => {
    let app: App | null = null;
    let cancelled = false;
    (async () => {
      try {
        // Vue's esm-bundler build expects these compile-time flags. next.config.ts does not define
        // them (no DefinePlugin), so set them as globals before Vue loads to keep the console clean.
        Object.assign(globalThis, {
          __VUE_OPTIONS_API__: true,
          __VUE_PROD_DEVTOOLS__: false,
          __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
        });
        const [{ createApp }, { createPinia }, root] = await Promise.all([
          import("vue/dist/vue.esm-bundler.js"),
          import("pinia"),
          load(),
        ]);
        if (cancelled || !host.current) return;
        app = createApp(root.default);
        app.use(createPinia());
        app.config.errorHandler = (err) => {
          console.error("[vue]", err);
        };
        app.mount(host.current);
        setState("ready");
      } catch (err) {
        console.error("[VueIsland]", err);
        if (!cancelled) setState("error");
      }
    })();
    return () => {
      cancelled = true;
      app?.unmount();
      app = null;
    };
  }, [load]);

  return (
    <div className="relative" style={{ minHeight }} role="region" aria-label={label} aria-busy={state === "loading"}>
      <div ref={host} />
      {state === "loading" && (
        <p className="absolute inset-0 grid place-items-center font-mono text-sm text-fg-muted">
          <span>
            <span className="text-accent">$</span> mounting Vue app…
          </span>
        </p>
      )}
      {state === "error" && (
        <p role="alert" className="absolute inset-0 grid place-items-center p-6 text-center text-sm text-danger">
          The Vue app failed to load. Reload the page to try again.
        </p>
      )}
    </div>
  );
}