implement ssr

This commit is contained in:
2026-09-06 23:29:00 -04:00
parent 4e381fe1bc
commit 06d31382e7
17 changed files with 477 additions and 56 deletions
+4 -3
View File
@@ -4,10 +4,11 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <!--ssr-head-->
<!--ssr-data-->
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"><!--ssr-outlet--></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/entry-client.tsx"></script>
</body> </body>
</html> </html>
+6 -4
View File
@@ -4,14 +4,15 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "node server.js",
"build": "tsc -b && vite build", "build": "tsc -b && vite build --outDir dist/client && vite build --ssr src/entry-server.tsx --outDir dist/server",
"lint": "eslint .", "start": "node server.js --prod",
"preview": "vite preview" "lint": "eslint ."
}, },
"dependencies": { "dependencies": {
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"express": "^5.2.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -20,6 +21,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/express": "^5.0.6",
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
+168
View File
@@ -0,0 +1,168 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Readable } from "node:stream";
import express from "express";
const root = path.dirname(fileURLToPath(import.meta.url));
const isProd =
process.argv.includes("--prod") || process.env.NODE_ENV === "production";
const port = Number(process.env.PORT ?? 5173);
const backendOrigin = process.env.BACKEND_ORIGIN ?? "http://localhost:3000";
const app = express();
/** @type {import("vite").ViteDevServer | undefined} */
let vite;
if (isProd) {
// Built assets, plus the same /api -> backend rewrite that vite.config.ts
// sets up for dev, so the browser talks to one origin either way.
app.use(express.static(path.join(root, "dist/client"), { index: false }));
app.use("/api", proxyToBackend);
} else {
const { createServer } = await import("vite");
vite = await createServer({
root,
appType: "custom",
server: { middlewareMode: true },
});
// Includes Vite's own /api proxy, asset serving and HMR.
app.use(vite.middlewares);
}
const prodTemplate = isProd
? await fs.readFile(path.join(root, "dist/client/index.html"), "utf-8")
: null;
app.use(async (req, res, next) => {
if (req.method !== "GET" && req.method !== "HEAD") return next();
const url = req.originalUrl;
try {
let template;
let entry;
if (vite) {
template = await vite.transformIndexHtml(
url,
await fs.readFile(path.join(root, "index.html"), "utf-8"),
);
entry = await vite.ssrLoadModule("/src/entry-server.tsx");
} else {
template = prodTemplate;
entry = await import(
pathToFileURL(path.join(root, "dist/server/entry-server.js")).href
);
}
const result = await entry.render(url, (apiPath) =>
fetch(new URL(apiPath, backendOrigin), {
headers: req.headers.cookie ? { cookie: req.headers.cookie } : {},
}),
);
const status = result.kind === "not-found" ? 404 : 200;
const html =
result.kind === "ssr"
? fillTemplate(template, result.head, result.html, result.data)
: fillTemplate(template, entry.defaultHead, "", null);
res.status(status).set({ "Content-Type": "text/html" }).end(html);
} catch (e) {
vite?.ssrFixStacktrace(e);
next(e);
}
});
app.listen(port, () => {
console.log(
`${isProd ? "Production" : "Dev"} server listening on http://localhost:${port}`,
);
});
/**
* Fills the SSR placeholders in index.html. `markup` is empty and `data` null
* for routes that aren't server-rendered, which leaves the original
* client-only shell.
*/
function fillTemplate(template, head, markup, data) {
const headTags = [
`<title>${escapeHtml(head.title)}</title>`,
`<meta name="description" content="${escapeHtml(head.description)}" />`,
].join("\n ");
const dataTag = data
? `<script>window.__SSR_DATA__ = ${serialize(data)}</script>`
: "";
return template
.replace("<!--ssr-head-->", headTags)
.replace("<!--ssr-outlet-->", markup)
.replace("<!--ssr-data-->", dataTag);
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** JSON for inline <script>: `<` is escaped so post content can't close it. */
function serialize(data) {
return JSON.stringify(data).replace(/</g, "\\u003c");
}
const REQUEST_HEADER_SKIP = new Set(["host", "connection", "content-length"]);
const RESPONSE_HEADER_SKIP = new Set([
"connection",
"content-encoding",
"content-length",
"keep-alive",
"set-cookie",
"transfer-encoding",
]);
/** Minimal /api -> backend proxy, mirroring the dev proxy's path rewrite. */
async function proxyToBackend(req, res, next) {
const hasBody = req.method !== "GET" && req.method !== "HEAD";
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (REQUEST_HEADER_SKIP.has(key)) continue;
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
}
try {
const upstream = await fetch(new URL(req.url, backendOrigin), {
method: req.method,
headers,
body: hasBody ? Readable.toWeb(req) : undefined,
duplex: hasBody ? "half" : undefined,
redirect: "manual",
});
res.status(upstream.status);
upstream.headers.forEach((value, key) => {
// fetch already decoded the body, hop-by-hop headers belong to this
// connection, and set-cookie needs its own pass to survive as separate
// headers rather than one comma-joined value.
if (RESPONSE_HEADER_SKIP.has(key)) return;
res.setHeader(key, value);
});
for (const cookie of upstream.headers.getSetCookie()) {
res.append("set-cookie", cookie);
}
if (upstream.body) {
Readable.fromWeb(upstream.body).pipe(res);
} else {
res.end();
}
} catch (e) {
next(e);
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Route, Routes } from "react-router-dom";
import Home from "./pages/home";
import LogIn from "./pages/login";
import Post from "./pages/post";
import Editor from "./pages/editor";
/** The route tree, shared by the browser and server entry points. */
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<LogIn />} />
<Route path="/post/:slug" element={<Post />} />
<Route path="/editor" element={<Editor />} />
<Route path="/editor/:slug" element={<Editor />} />
</Routes>
);
}
@@ -0,0 +1,22 @@
import { useHydrated } from "../ssr/hydrated";
// The server render and the hydration pass use a fixed locale and timezone so
// both produce identical markup. Once hydrated we switch to the visitor's own
// locale and timezone, which is what the page showed before it was
// server-rendered.
const stableFormatter = new Intl.DateTimeFormat("en-US", {
timeZone: "UTC",
dateStyle: "short",
timeStyle: "medium",
});
export default function FormattedDate({ value }: { value: string }) {
const hydrated = useHydrated();
const date = new Date(value);
return (
<time dateTime={date.toISOString()}>
{hydrated ? date.toLocaleString() : stableFormatter.format(date)}
</time>
);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Post } from "../utils/types"; import type { Post } from "../utils/types";
import FormattedDate from "./formattedDate";
export default function PostRow({ post }: { post: Post }) { export default function PostRow({ post }: { post: Post }) {
return ( return (
@@ -8,7 +9,7 @@ export default function PostRow({ post }: { post: Post }) {
<h1>{post.title}</h1> <h1>{post.title}</h1>
<p>{post.description}</p> <p>{post.description}</p>
<p className="text-xs opacity-50"> <p className="text-xs opacity-50">
Posted on {new Date(post.createdAt).toLocaleString()} Posted on <FormattedDate value={post.createdAt} />
</p> </p>
</div> </div>
</a> </a>
+36
View File
@@ -0,0 +1,36 @@
import { createRoot, hydrateRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./providers/AuthProvider";
import { SsrDataProvider } from "./ssr/context";
import type { SsrData } from "./ssr/types";
import App from "./app";
import "./assets/index.css";
declare global {
interface Window {
__SSR_DATA__?: SsrData;
}
}
const ssrData = window.__SSR_DATA__ ?? null;
delete window.__SSR_DATA__;
const tree = (
<SsrDataProvider data={ssrData}>
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</SsrDataProvider>
);
const container = document.getElementById("root")!;
// Server-rendered pages ship markup to hydrate; every other route is still a
// plain client-side render into an empty root.
if (ssrData) {
hydrateRoot(container, tree);
} else {
createRoot(container).render(tree);
}
+42
View File
@@ -0,0 +1,42 @@
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import { AuthProvider } from "./providers/AuthProvider";
import { SsrDataProvider } from "./ssr/context";
import { matchSsrRoute, type ApiFetch } from "./ssr/routes";
import type { SsrData, SsrHead } from "./ssr/types";
import App from "./app";
type RenderResult =
/** Not a server-rendered route - serve the client-only shell. */
| { kind: "spa" }
/** Server-rendered route whose content is missing - shell, with a 404. */
| { kind: "not-found" }
| { kind: "ssr"; html: string; data: SsrData; head: SsrHead };
async function render(url: string, api: ApiFetch): Promise<RenderResult> {
const pathname = new URL(url, "http://ssr.invalid").pathname;
const matched = matchSsrRoute(pathname);
if (!matched) return { kind: "spa" };
const loaded = await matched.route.load(api, matched.params);
if (!loaded) return { kind: "not-found" };
// The visitor's session is deliberately not resolved here: auth-dependent
// chrome renders logged-out on the server and AuthProvider fills it in after
// hydration, which keeps these pages cacheable and hydration-safe.
const html = renderToString(
<SsrDataProvider data={loaded.data}>
<AuthProvider>
<StaticRouter location={url}>
<App />
</StaticRouter>
</AuthProvider>
</SsrDataProvider>,
);
return { kind: "ssr", html, data: loaded.data, head: loaded.head };
}
export { render };
export { defaultHead } from "./ssr/routes";
export type { RenderResult };
-38
View File
@@ -1,38 +0,0 @@
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { AuthProvider } from "./providers/AuthProvider";
import "./assets/index.css";
import Home from "./pages/home";
import LogIn from "./pages/login";
import Post from "./pages/post";
import Editor from "./pages/editor";
const router = createBrowserRouter([
{
path: "/",
element: <Home />,
},
{
path: "/login",
element: <LogIn />,
},
{
path: "/post/:slug",
element: <Post />,
},
{
path: "/editor",
element: <Editor />,
},
{
path: "/editor/:slug",
element: <Editor />,
},
]);
createRoot(document.getElementById("root")!).render(
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>,
);
+9 -3
View File
@@ -3,15 +3,21 @@ import PostRow from "../components/postRow";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuth } from "../providers/AuthProvider"; import { useAuth } from "../providers/AuthProvider";
import { useSsrData } from "../ssr/context";
import type { Post } from "../utils/types"; import type { Post } from "../utils/types";
export default function Home() { export default function Home() {
const [posts, setPosts] = useState<Post[] | null>(null); const ssr = useSsrData("home");
const [loaded, setLoaded] = useState<boolean>(false); const [seededByServer] = useState<boolean>(() => ssr !== null);
const [posts, setPosts] = useState<Post[] | null>(ssr?.posts ?? null);
const [loaded, setLoaded] = useState<boolean>(seededByServer);
const auth = useAuth(); const auth = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
// Already rendered on the server with the post list baked in.
if (seededByServer) return;
fetch("/api/posts", { fetch("/api/posts", {
method: "GET", method: "GET",
credentials: "include", credentials: "include",
@@ -25,7 +31,7 @@ export default function Home() {
setLoaded(true); setLoaded(true);
} }
}); });
}, []); }, [seededByServer]);
const PostList = () => { const PostList = () => {
const mappedPosts = posts?.map((v, k) => { const mappedPosts = posts?.map((v, k) => {
+15 -5
View File
@@ -2,16 +2,23 @@ import Layout from "../components/layout";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { useAuth } from "../providers/AuthProvider"; import { useAuth } from "../providers/AuthProvider";
import { useSsrData } from "../ssr/context";
import FormattedDate from "../components/formattedDate";
import type { Post } from "../utils/types"; import type { Post } from "../utils/types";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
export default function Post() { export default function Post() {
const [post, setPost] = useState<Post | null>(null); const ssr = useSsrData("post");
const [seededByServer] = useState<boolean>(() => ssr !== null);
const [post, setPost] = useState<Post | null>(ssr?.post ?? null);
const params = useParams(); const params = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const auth = useAuth(); const auth = useAuth();
useEffect(() => { useEffect(() => {
// Already rendered on the server with the post baked in.
if (seededByServer) return;
if (params.slug) { if (params.slug) {
fetch("/api/posts/" + params.slug, { fetch("/api/posts/" + params.slug, {
method: "GET", method: "GET",
@@ -25,7 +32,7 @@ export default function Post() {
} }
}); });
} }
}, []); }, [seededByServer, params.slug]);
function deletePost() { function deletePost() {
if (!auth.user || !post) return; if (!auth.user || !post) return;
@@ -63,9 +70,12 @@ export default function Post() {
</div> </div>
<p className="text-sm opacity-50"> <p className="text-sm opacity-50">
Posted {new Date(post.createdAt).toLocaleString()}{" "} Posted <FormattedDate value={post.createdAt} />{" "}
{post.updatedAt !== post.createdAt && {post.updatedAt !== post.createdAt && (
"- updated on " + new Date(post.updatedAt).toLocaleString()} <>
- updated on <FormattedDate value={post.updatedAt} />
</>
)}
</p> </p>
{auth.user && ( {auth.user && (
<div className="inline-block space-x-2"> <div className="inline-block space-x-2">
+34
View File
@@ -0,0 +1,34 @@
import { createContext, useContext, type ReactNode } from "react";
import { useHydrated } from "./hydrated";
import type { SsrData } from "./types";
const SsrDataContext = createContext<SsrData | null>(null);
export function SsrDataProvider({
data,
children,
}: {
data: SsrData | null;
children: ReactNode;
}) {
// The prefetched payload seeds the server render and the hydration pass that
// has to match it, then goes away: from there on, client-side navigation
// fetches fresh data rather than replaying a payload from page load.
const hydrated = useHydrated();
return (
<SsrDataContext value={hydrated ? null : data}>{children}</SsrDataContext>
);
}
/**
* Returns the server-prefetched data for a page, or null when the page wasn't
* server-rendered (or is being mounted by a client-side navigation).
*/
export function useSsrData<T extends SsrData["route"]>(
route: T,
): Extract<SsrData, { route: T }> | null {
const data = useContext(SsrDataContext);
if (data?.route !== route) return null;
return data as Extract<SsrData, { route: T }>;
}
+14
View File
@@ -0,0 +1,14 @@
import { useSyncExternalStore } from "react";
const subscribe = () => () => {};
const onClient = () => true;
const onServer = () => false;
/**
* False while rendering on the server and during the hydration pass, true
* afterwards. Lets a component render server-safe markup first and switch to
* browser-only output once hydration has matched.
*/
export function useHydrated() {
return useSyncExternalStore(subscribe, onClient, onServer);
}
+76
View File
@@ -0,0 +1,76 @@
import { matchPath } from "react-router-dom";
import type { Post } from "../utils/types";
import type { SsrData, SsrHead } from "./types";
/** Fetches a path against the backend API, carrying the visitor's cookies. */
type ApiFetch = (path: string) => Promise<Response>;
type SsrRoute = {
path: string;
load: (
api: ApiFetch,
params: Record<string, string | undefined>,
) => Promise<{ data: SsrData; head: SsrHead } | null>;
};
const SITE_NAME = "Alex / dev + QA";
const SITE_DESCRIPTION =
"random thoughts, sometimes LinkedIn slop, sometimes just about my interests.";
const defaultHead: SsrHead = {
title: SITE_NAME,
description: SITE_DESCRIPTION,
};
/**
* The only routes that get server-rendered. Anything not listed here is served
* as the plain client-only SPA shell, so /login and /editor keep behaving
* exactly as they did before.
*
* A loader returning null means "this page has no content" and the server
* answers 404 with the shell.
*/
const ssrRoutes: SsrRoute[] = [
{
path: "/",
load: async (api) => {
const res = await api("/posts");
if (!res.ok) return null;
const posts: Post[] = await res.json();
return {
data: { route: "home", posts },
head: defaultHead,
};
},
},
{
path: "/post/:slug",
load: async (api, params) => {
if (!params.slug) return null;
const res = await api("/posts/" + encodeURIComponent(params.slug));
if (!res.ok) return null;
const post: Post = await res.json();
return {
data: { route: "post", post },
head: {
title: `${post.title} - ${SITE_NAME}`,
description: post.description,
},
};
},
},
];
function matchSsrRoute(pathname: string) {
for (const route of ssrRoutes) {
const match = matchPath(route.path, pathname);
if (match) return { route, params: match.params };
}
return null;
}
export { matchSsrRoute, defaultHead };
export type { ApiFetch };
+18
View File
@@ -0,0 +1,18 @@
import type { Post } from "../utils/types";
/**
* Data the server prefetched for a server-rendered page. It is embedded in the
* HTML response so hydration starts from exactly what was rendered instead of
* re-fetching on mount.
*/
type SsrData =
| { route: "home"; posts: Post[] }
| { route: "post"; post: Post };
/** Page-specific <head> content. Server-side only, never sent to the client. */
type SsrHead = {
title: string;
description: string;
};
export type { SsrData, SsrHead };
+6 -2
View File
@@ -3,8 +3,12 @@ import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ isSsrBuild }) => ({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
build: {
// Static files belong with the client build only.
copyPublicDir: !isSsrBuild,
},
server: { server: {
proxy: { proxy: {
"/api": { "/api": {
@@ -14,4 +18,4 @@ export default defineConfig({
}, },
}, },
}, },
}); }));
+6
View File
@@ -279,6 +279,9 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.3.3 specifier: ^4.3.3
version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))
express:
specifier: ^5.2.1
version: 5.2.1
react: react:
specifier: ^19.2.8 specifier: ^19.2.8
version: 19.2.8 version: 19.2.8
@@ -298,6 +301,9 @@ importers:
'@eslint/js': '@eslint/js':
specifier: ^10.0.1 specifier: ^10.0.1
version: 10.0.1(eslint@10.10.0(jiti@2.7.0)) version: 10.0.1(eslint@10.10.0(jiti@2.7.0))
'@types/express':
specifier: ^5.0.6
version: 5.0.6
'@types/node': '@types/node':
specifier: ^24.13.3 specifier: ^24.13.3
version: 24.13.3 version: 24.13.3