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.
Interactive demo
$ 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 <head></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>
);
}