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
+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 FormattedDate from "./formattedDate";
export default function PostRow({ post }: { post: Post }) {
return (
@@ -8,7 +9,7 @@ export default function PostRow({ post }: { post: Post }) {
<h1>{post.title}</h1>
<p>{post.description}</p>
<p className="text-xs opacity-50">
Posted on {new Date(post.createdAt).toLocaleString()}
Posted on <FormattedDate value={post.createdAt} />
</p>
</div>
</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 { useNavigate } from "react-router-dom";
import { useAuth } from "../providers/AuthProvider";
import { useSsrData } from "../ssr/context";
import type { Post } from "../utils/types";
export default function Home() {
const [posts, setPosts] = useState<Post[] | null>(null);
const [loaded, setLoaded] = useState<boolean>(false);
const ssr = useSsrData("home");
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 navigate = useNavigate();
useEffect(() => {
// Already rendered on the server with the post list baked in.
if (seededByServer) return;
fetch("/api/posts", {
method: "GET",
credentials: "include",
@@ -25,7 +31,7 @@ export default function Home() {
setLoaded(true);
}
});
}, []);
}, [seededByServer]);
const PostList = () => {
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 { useParams, useNavigate } from "react-router-dom";
import { useAuth } from "../providers/AuthProvider";
import { useSsrData } from "../ssr/context";
import FormattedDate from "../components/formattedDate";
import type { Post } from "../utils/types";
import Markdown from "react-markdown";
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 navigate = useNavigate();
const auth = useAuth();
useEffect(() => {
// Already rendered on the server with the post baked in.
if (seededByServer) return;
if (params.slug) {
fetch("/api/posts/" + params.slug, {
method: "GET",
@@ -25,7 +32,7 @@ export default function Post() {
}
});
}
}, []);
}, [seededByServer, params.slug]);
function deletePost() {
if (!auth.user || !post) return;
@@ -63,9 +70,12 @@ export default function Post() {
</div>
<p className="text-sm opacity-50">
Posted {new Date(post.createdAt).toLocaleString()}{" "}
{post.updatedAt !== post.createdAt &&
"- updated on " + new Date(post.updatedAt).toLocaleString()}
Posted <FormattedDate value={post.createdAt} />{" "}
{post.updatedAt !== post.createdAt && (
<>
- updated on <FormattedDate value={post.updatedAt} />
</>
)}
</p>
{auth.user && (
<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 };