implement ssr
This commit is contained in:
@@ -4,10 +4,11 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<!--ssr-head-->
|
||||
<!--ssr-data-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<div id="root"><!--ssr-outlet--></div>
|
||||
<script type="module" src="/src/entry-client.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"dev": "node server.js",
|
||||
"build": "tsc -b && vite build --outDir dist/client && vite build --ssr src/entry-server.tsx --outDir dist/server",
|
||||
"start": "node server.js --prod",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -20,6 +21,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -3,8 +3,12 @@ import { defineConfig } from "vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ isSsrBuild }) => ({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
// Static files belong with the client build only.
|
||||
copyPublicDir: !isSsrBuild,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
@@ -14,4 +18,4 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user