Skip to content

Commit 9b2a2f7

Browse files
committed
feat: server side rendering
1 parent 43629f7 commit 9b2a2f7

12 files changed

+291
-39
lines changed

index.html

+2-5
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
<!DOCTYPE html>
22
<html lang="en">
33
<head>
4-
<meta charset="UTF-8" />
5-
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
6-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7-
<title>Vite + React + TS</title>
4+
<link href="/src/index.css" rel="stylesheet" />
85
</head>
96
<body>
107
<div id="root"></div>
11-
<script type="module" src="/src/main.tsx"></script>
8+
<script type="module" src="/src/entry-client.tsx"></script>
129
</body>
1310
</html>

src/App.tsx

+13-30
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,17 @@
1-
import { useState } from 'react'
2-
import reactLogo from './assets/react.svg'
3-
import './App.css'
1+
import { Routes, Route, RouteProps } from "react-router-dom";
42

5-
function App() {
6-
const [count, setCount] = useState(0)
3+
interface Props {
4+
routes: RouteProps[];
5+
}
76

7+
const App: React.FC<Props> = ({ routes }) => {
88
return (
9-
<div className="App">
10-
<div>
11-
<a href="https://vitejs.dev" target="_blank">
12-
<img src="/vite.svg" className="logo" alt="Vite logo" />
13-
</a>
14-
<a href="https://reactjs.org" target="_blank">
15-
<img src={reactLogo} className="logo react" alt="React logo" />
16-
</a>
17-
</div>
18-
<h1>Vite + React</h1>
19-
<div className="card">
20-
<button onClick={() => setCount((count) => count + 1)}>
21-
count is {count}
22-
</button>
23-
<p>
24-
Edit <code>src/App.tsx</code> and save to test HMR
25-
</p>
26-
</div>
27-
<p className="read-the-docs">
28-
Click on the Vite and React logos to learn more
29-
</p>
30-
</div>
31-
)
32-
}
9+
<Routes>
10+
{routes.map((route) => (
11+
<Route key={route.path} {...route}></Route>
12+
))}
13+
</Routes>
14+
);
15+
};
3316

34-
export default App
17+
export default App;

src/components/Async/Async.tsx

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React from "react";
2+
import { matchPath } from "react-router";
3+
4+
const Async = (
5+
path: string,
6+
dynamicImport: () => Promise<{ default: React.FC }>
7+
): React.ReactNode => {
8+
const Component = React.lazy(dynamicImport);
9+
const isMatch = matchPath(path, window.location.pathname);
10+
11+
// This is required to prevent React complaining from
12+
// client/server hydration mismatch. It should render
13+
// sync until the app is mounted completely. isMatch
14+
// is also required to render only matching routes - others
15+
// should be suspended.
16+
if (isMatch) {
17+
return <Component />;
18+
}
19+
20+
return (
21+
<React.Suspense fallback={<div className="list">Loading...</div>}>
22+
<Component />
23+
</React.Suspense>
24+
);
25+
};
26+
27+
export default Async;

src/components/Async/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from "./Async";

src/context.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createContext } from "react";
2+
3+
export default createContext({});

src/entry-server.tsx

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { StaticRouter } from "react-router-dom/server";
2+
import { renderToString } from "react-dom/server";
3+
import createRoutes from "./routes";
4+
import Context from "./context";
5+
import App from "./App";
6+
7+
interface RenderReturn {
8+
status: number;
9+
content: string;
10+
head: string;
11+
}
12+
13+
export type RenderFunction = (url: string) => Promise<RenderReturn>;
14+
15+
const defaultSEO: SEO = {
16+
title: "Vite + React (SSR, SSG, SPA)",
17+
description: "Mono repo template for apps needing ssr, ssg and/or spa.",
18+
domain: {
19+
name: "",
20+
url: "",
21+
},
22+
twitter: {
23+
card: "summary_large_image",
24+
creator: "@savasvedova",
25+
},
26+
};
27+
28+
export const render: RenderFunction = async (url) => {
29+
const { routes, head, context } = await createRoutes(url);
30+
const tags = {
31+
...defaultSEO,
32+
...head,
33+
};
34+
35+
// Prefix the title with the domain.name property.
36+
tags.title =
37+
`${tags.domain?.name ? tags.domain.name + " | " : ""}` + tags.title;
38+
39+
return {
40+
status: 200,
41+
content:
42+
renderToString(
43+
<Context.Provider value={context}>
44+
<StaticRouter location={url}>
45+
<App routes={routes} />
46+
</StaticRouter>
47+
</Context.Provider>
48+
) +
49+
(context
50+
? `<script>window.CONTEXT = ${JSON.stringify(context)}</script>`
51+
: ""),
52+
head: [
53+
`<title>${tags.title}</title>`,
54+
`<meta charset="utf-8" />`,
55+
`<meta name="viewport" content="width=device-width, initial-scale=1.0" />`,
56+
`<meta name="description" content="${tags.description}" />`,
57+
`<meta property="og:title" content="${tags.title}" />`,
58+
`<meta property="og:url" content="${tags.domain?.url}" />`,
59+
`<meta property="og:description" content="${tags.description}" />`,
60+
`<meta property="og:image" content="${tags.domain?.url}/logo.svg" />`,
61+
`<meta name="twitter:card" content="${tags.twitter!.card}" />`,
62+
`<meta name="twitter:creator" content="${tags.twitter!.creator}" />`,
63+
`<meta name="twitter:title" content="${tags.title}" />`,
64+
`<meta name="twitter:description" content="${tags.description}" />`,
65+
`<link rel="icon" type="image/svg+xml" href="/logo.svg" />`,
66+
]
67+
.join("\n")
68+
.trim(),
69+
};
70+
};

src/routes.tsx

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { RouteProps } from "react-router";
2+
import { matchPath } from "react-router";
3+
import Async from "~/components/Async";
4+
5+
interface Route {
6+
path: string;
7+
import: () => Promise<{ default: React.FC; fetchData?: FetchDataFunc }>;
8+
}
9+
10+
type RouteExtended = RouteProps & {
11+
data?: { head: SEO };
12+
};
13+
14+
const routes: Route[] = [
15+
{ path: "/", import: () => import("~/pages") },
16+
{ path: "/:name", import: () => import("~/pages/[name]") },
17+
];
18+
19+
const isServerSide = typeof window === "undefined";
20+
21+
export default async (
22+
url: string
23+
): Promise<{ routes: RouteExtended[]; head?: SEO; context: any }> => {
24+
const allRoutes: RouteExtended[] = [];
25+
let head: SEO | undefined;
26+
let context: any;
27+
28+
for (const route of routes) {
29+
let element: React.ReactNode;
30+
const match = matchPath(route.path, url);
31+
32+
// For the server-side application, we do not need code-splitting.
33+
// Also, this will ensure the server-side build is compatible with
34+
// serverless environments.
35+
if (isServerSide) {
36+
const mod = await route.import();
37+
element = <mod.default />;
38+
39+
if (match) {
40+
const data = await mod?.fetchData?.(
41+
match.params as Record<string, string>
42+
);
43+
44+
if (data?.head) {
45+
head = data.head;
46+
}
47+
48+
if (data?.context) {
49+
context = data.context;
50+
}
51+
}
52+
} else if (!isServerSide) {
53+
element = Async(route.path, route.import);
54+
}
55+
56+
allRoutes.push({ path: route.path, element });
57+
}
58+
59+
return { routes: allRoutes, head, context };
60+
};

src/types/fetch-data.d.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
declare type FetchDataFunc = (
2+
match: Record<string, string>
3+
) => Promise<{ head: SEO; context: any }>;

src/types/seo.d.ts

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
declare interface SEO {
2+
// The title tag.
3+
title?: string;
4+
5+
// The description tag. Used in `description` and `og:description` tags.
6+
description?: string;
7+
8+
domain?: {
9+
// The domain name. This is used to prefix the title tag.
10+
name?: string;
11+
12+
// The application URL. Used in `og:url` tag.
13+
url?: string;
14+
};
15+
16+
twitter?: {
17+
// Used in twitter:card meta tag. One of `summary` or `summary_large_image`.
18+
card?: string;
19+
20+
// Used in twitter:author
21+
creator?: string;
22+
};
23+
}

src/vite-server.ts

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { RenderFunction } from "./entry-server";
2+
import fs from "fs";
3+
import path from "path";
4+
import express from "express";
5+
import { fileURLToPath } from "node:url";
6+
import { createServer as createViteServer } from "vite";
7+
8+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
9+
10+
function injectContent(head: string, content: string, template: string) {
11+
return template
12+
.replace(`</head>`, `${head}</head>`)
13+
.replace(`<div id="root"></div>`, `<div id="root">${content}</div>`);
14+
}
15+
16+
async function createServer() {
17+
const app = express();
18+
19+
// Create Vite server in middleware mode and configure the app type as
20+
// 'custom', disabling Vite's own HTML serving logic so parent server
21+
// can take control
22+
const vite = await createViteServer({
23+
server: { middlewareMode: true },
24+
appType: "custom",
25+
});
26+
27+
// use vite's connect instance as middleware
28+
// if you use your own express router (express.Router()), you should use router.use
29+
app.use(vite.middlewares);
30+
31+
app.get("*", async (req, res, next) => {
32+
try {
33+
const url: string = req.originalUrl.split(/\?#/)[0] || "/";
34+
35+
// // 1. Read and apply Vite HTML transforms. This injects the Vite HMR client, and
36+
// // also applies HTML transforms from Vite plugins, e.g. global preambles
37+
// // from @vitejs/plugin-react
38+
const template: string = await vite.transformIndexHtml(
39+
req.originalUrl,
40+
fs.readFileSync(path.resolve(__dirname, "..", "index.html"), "utf-8")
41+
);
42+
43+
// 2. Load the server entry. vite.ssrLoadModule automatically transforms
44+
// your ESM source code to be usable in Node.js! There is no bundling
45+
// required, and provides efficient invalidation similar to HMR.
46+
const { render } = (await vite.ssrLoadModule("./src/entry-server")) as {
47+
render: RenderFunction;
48+
};
49+
50+
const rendered = await render(url);
51+
52+
return res
53+
.status(rendered.status)
54+
.set({ "Content-Type": "text/html" })
55+
.send(injectContent(rendered.head, rendered.content, template));
56+
} catch (e) {
57+
// If an error is caught, let Vite fix the stack trace so it maps back to
58+
// your actual source code.
59+
if (e instanceof Error) {
60+
vite.ssrFixStacktrace(e);
61+
}
62+
63+
next(e);
64+
}
65+
});
66+
67+
app.listen(5173, () => {
68+
console.log(`Server listening on http://localhost:5173`);
69+
});
70+
}
71+
72+
createServer();

tsconfig.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
"resolveJsonModule": true,
1515
"isolatedModules": true,
1616
"noEmit": true,
17-
"jsx": "react-jsx"
17+
"jsx": "react-jsx",
18+
"paths": {
19+
"~/*": ["./src/*"]
20+
}
1821
},
1922
"include": ["src"],
2023
"references": [{ "path": "./tsconfig.node.json" }]

vite.config.ts

+13-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1-
import { defineConfig } from 'vite'
2-
import react from '@vitejs/plugin-react'
1+
import { defineConfig } from "vite";
2+
import path from "node:path";
3+
import react from "@vitejs/plugin-react";
34

45
// https://vitejs.dev/config/
56
export default defineConfig({
7+
resolve: {
8+
alias: [
9+
{
10+
find: /^~/,
11+
replacement: path.resolve(__dirname, "src"),
12+
},
13+
],
14+
extensions: [".mjs", ".js", ".ts", ".jsx", ".tsx", ".json"],
15+
},
616
plugins: [react()],
7-
})
17+
});

0 commit comments

Comments
 (0)