Building a SSR framework from Scratch
There's also a video podcast of this post with more information.
Have you ever wondered how frameworks like NextJS work under the hood? Today we will be building a scaled down version of NextJS.
Before we get started, let's take a look at the end result:
export const getServerSideProps = async ({ prisma }) => {
let user = await prisma.user.findFirst();
return { name: user?.name };
};
type Props = {
name: string;
};
const Homepage = ({ name }: Props) => {
return (
<div>
<p>Hello from prisma, {name}</p>
</div>
);
};
We'll be able to create files inside of a pages
folder, when the route matches the file name, our server will call getServerSideProps
and inject it into the component we export below it.
When the browser requests a page, the server will return the html markup, the browser will display it, and then React will mount on the client side afterwards.
If this sounds like a fun project, keep reading to see how this works!
High Level Concepts
This project uses a few different pieces. I'm going to give a brief explanation of the concepts / tools used in the project before we get started.
Vite is a module bundler, similar to webpack, snowpack, and others. It uses esbuild under the hood for insane performance. I chose this bundler because of its speed and simple setup. It also has a simple setup process for hot module replacement. When we are editing one component on a page with 50 of them, it will update without a page load, instantly.
SSR is server side rendering. Frameworks like create-react-app do not do SSR. NextJS and Sveltekit do use SSR. So what is the difference? SSR frameworks generate the html content of your page on the server when the client requests a page. This is important for SEO, and slow devices especially. Without SSR, all pages return the same index.html
file. This is where the term "Single Page App" comes from. Client side only apps serve the same file for all pages, and the JS runs on the client browser and renders the correct page.
React is a frontend UI library for creating user interfaces. It's important to note, Vite supports multiple frontend frameworks. This tutorial can be adapted to use a different UI library such as Vue or Svelte, with minor code changes to the framework code itself!
Prisma is the best tool for interacting with databases in Node / TS. We're going to add support for it in our mini framework, by letting anyone query data above their page components. Instead of needing a REST, GraphQL, or other api endpoint. Our framework will let users query prisma directly above components that need the data. If you've never used Prisma before, we'll cover the basics here.
Contents
Be sure to bookmark the Github Repo for this project. Each commit is the code from each section to help with your reference.
This framework will not be full featured. We don't go over production builds, and there are multiple edge cases we won't go over. The scope of this post was getting too large to cover any more. However, if this post gets enough interest, I can make a part 2 that covers some ways to make it more feature complete.
I tried my best to split up the tutorial into logical sections. After the Server Side Rendering
step is complete, you will be able to start the server and see each new feature at the end of every step!
Getting Started
Let's create a new folder called myapp
. We'll make our framework inside this folder for simplicity's sake. Long term we would want to split this out better.
Let's create a package.json
in the folder, and paste in these contents:
{
"name": "moovite",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "ts-node server.ts"
},
"author": "frontend.blog",
"license": "ISC",
"devDependencies": {
"@types/express": "^4.17.13",
"@types/node": "^16.3.1",
"@types/react": "^17.0.14",
"@types/react-dom": "^17.0.9",
"prisma": "^2.26.0"
},
"dependencies": {
"@prisma/client": "^2.26.0",
"@vitejs/plugin-react-refresh": "^1.3.5",
"express": "^4.17.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"ts-node": "^10.1.0",
"typescript": "^4.3.5",
"vite": "^2.4.1"
}
}
It's faster than having you manually install everything, and ensures we have the same versions of everything. Be sure to run npm install
after creating the file.
Setup Vite
Vite is configured by making a vite.config.ts
file in the root of our project:
import reactRefresh from "@vitejs/plugin-react-refresh";
export default {
// enables hot module replacement for react
plugins: [reactRefresh()],
esbuild: {
// injects the React import statement to jsx files
jsxInject: `import React from 'react';`,
},
build: {
minify: false,
},
};
We're telling Vite we want to use a single plugin, reactRefresh
. This package handles hot reloading parts of the page when we edit components.
Next, we are injecting a React import, so that we don't need to worry about importing it when using JS on the server.
Let's create our main index.html
next. This will be automatically transformed by our Vite server and injected with the page's server rendered html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/moovite/entry.tsx"></script>
</body>
</html>
For now, we will hardcode Vite App
in the title. This is something we can solve in a part 2 post one day. When the user requests a page, the server will render the html markup, and place it inside the app-html
comment.
See the code for this completed section
Server Side Rendering
We're going to start with one problem at a time, get it working, and move on. With the basic configuration out of the way, we will start on the SSR code.
Let's create a server.ts
file in the root:
import express from "express";
import { createServer as createViteServer } from "vite";
import { serverRenderRoute } from "./moovite/ssr/serverRenderRoute";
async function createServer() {
// creates a standard express app
const app = express();
// create vite using ssr mode
const vite = await createViteServer({
server: { middlewareMode: "ssr" },
});
// register vite's middleware
app.use(vite.middlewares);
// when a page is requested, call our serverRenderRoute method
app.use("*", serverRenderRoute({ vite }));
// start the server on port 3000
app.listen(3000, () => console.log("listening on :3000"));
}
createServer();
This is a pretty standard express setup. We are creating a vite server according to the official docs. After that, we are registering one route, app.use(*)
which will call a serverRenderRoute
function that we need to work on next.
Create a new folder inside of myapp
, called moovite
. Pronounced move-it
, I thought this was a fun name for a framework that helps you move even faster with Vite.
moovite/ssr/serverRenderRoute.ts
import { RequestHandler } from "express-serve-static-core";
import React from "react";
import ReactDOMServer from "react-dom/server";
import { ViteDevServer } from "vite";
import { pageLoader } from "./pageLoader";
type Props = {
vite: ViteDevServer;
};
export const serverRenderRoute =
({ vite }: Props): RequestHandler =>
async (req, res) => {
// this will be `/` or `/test` depending on the page requested
const url = req.originalUrl;
try {
let { template, Page, App, props } = await pageLoader({
// pass the url, and vite to a pageLoader function
// we will make next
url,
vite,
});
// render the component to its html
// Since we are on the server using plain TS, and outside
// of Vite, we are not using JSX here
const appHtml = await ReactDOMServer.renderToString(
React.createElement(App, {
page: {
props,
path: req.originalUrl,
component: Page,
},
})
);
// Inject the app-rendered HTML into the template.
const html = template
.replace(`<!--app-html-->`, appHtml)
.replace(
"</head>",
`<script type="text/javascript">window._MOOVITE_PROPS_ = ${JSON.stringify(
props
)}</script></head>`
);
// Send the rendered HTML back.
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (e) {
// If an error is caught, let vite fix the stracktrace so it maps back to
// your actual source code.
vite.ssrFixStacktrace(e);
console.error(e);
res.status(500).end(e.message);
}
};
Alright, so this is a lot of stuff to break down. First we are calling out to a pageLoader
function that we will be creating next. It returns us the current component based on the url, its props by calling getServerSideProps
, and the top level App component. All of which we will be creating soon.
Next, we are rendering our App
to html, and then placing that into our index.html
template.
Lastly, we inject the component's props into a script tag. This will be explained later when we setup client side hydration.
The entire thing is wrapped in a try / catch
that will spit out errors from vite if we have any syntax errors or other problems during rendering.
Now we will create pageLoader.ts
inside the same folder:
import fs from "fs";
import path from "path";
import { ViteDevServer } from "vite";
import { urlToFilePath } from "./urlToFilePath";
type Props = {
url: string;
vite: ViteDevServer;
};
type PageLoaderResult = {
template: string;
Page: any;
App: any;
props: any;
};
export const pageLoader = async ({
url,
vite,
}: Props): Promise<PageLoaderResult> => {
// 1. Read index.html
let template = fs.readFileSync(
path.resolve(process.cwd(), "index.html"),
"utf-8"
);
// 2. Apply vite HTML transforms. This injects the vite HMR client, and
// also applies HTML transforms from Vite plugins, e.g. global preambles
// from @vitejs/plugin-react-refresh
template = await vite.transformIndexHtml(url, template);
// 3. Load the server entry. vite.ssrLoadModule automatically transforms
// your ESM source code to be usable in Node.js! There is no bundling
// required, and provides efficient invalidation similar to HMR.
const [{ default: Page, getServerSideProps }, { App }] = await Promise.all([
vite.ssrLoadModule(`/src/pages${urlToFilePath(url)}`),
vite.ssrLoadModule(`/moovite/entry.tsx`),
]);
let props = {};
if (getServerSideProps) props = await getServerSideProps();
return { template, Page, props, App };
};
I kept comments from the official vite SSR examples, which explains most of what we are doing. The last part to call out is our Promise.all
near the end. We are loading our main entry point into our app, but we also load the page being requested.
For instance, if a user is loading /test
we need to grab that component's getServerSideProps
method, and get the data from it.
With those two sections out of the way, we have one more small file to create.
urlToFilePath.ts
export const urlToFilePath = (url: string) => {
let lastCharacter = url[url.length - 1];
if (lastCharacter === "/") return `${url}index.tsx`;
return `${url}.tsx`;
};
This function is very basic for now, it does not cover all of the many things needed for something like NextJS. When a user requests /
in the url, this function will return /index.tsx
as the component name. If you go to /test
it will return /test.tsx
.
NextJS supports a lot more important scenarios, like turning a request for /test/:id
into loading /test/[id].tsx
. For tutorial purposes we are only supporting simple url paths that map exactly to component filenames.
Okay, we are very close to a stopping point where we can test out what we've done! We need to make a couple page components.
Create src/pages/index.tsx
in the root of your project:
const Homepage = () => {
return (
<div onClick={() => console.log("hello")}>
Hello from moovite
</div>
);
};
export default Homepage;
and src/pages/test.tsx
const Test = () => {
return (
<div>
Hello from test
</div>
);
};
export default Test;
Lastly, we will create moovite/entry.tsx
this will represent the root component of our entire app. We will explain this more in the client side routing section later.
import { createElement } from "react";
type Props = {
page?: any;
};
export const App = ({ page }: Props) => {
return createElement(page.component, page.props);
};
For now, it is expecting a page prop, with a component, and its props. If we take a look back at our pageLoader.ts
again, you will see:
const [{ default: Page, getServerSideProps }, { App }] = await
Promise.all([
vite.ssrLoadModule(`/src/pages${urlToFilePath(url)}`),
vite.ssrLoadModule(`/moovite/entry.tsx`),
]);
We are grabbing an App
export from the entry.tsx. When we open serverRenderRoute.ts
this code should make more sense now:
let { template, Page, App, props } = await pageLoader({
url,
vite,
});
const appHtml = await ReactDOMServer.renderToString(
React.createElement(App, {
page: {
props,
path: req.originalUrl,
component: Page,
},
})
);
We are creating our App
from the entry, and giving it the props and page component to render. Right now, it seems a little pointless, why not render Page
directly? Why do we need an App
component at the top level?
The answer is: for client side routing.
If we only send /test
to the client, and /test
wants to render a button that goes to the /
page, it would have to do a hard page load. Instead, we render an App
component that knows about all the routes in our application. This will make more sense in the client side routing section.
See the code for this completed section
Can we see something yet?
That was a LOT of code so far. Go ahead and run npm start
and the server should start successfully. Load up http://localhost:3000
in your browser.
This is a good time to mention the git repo once more:
You can see each step of this post in each commit, and pull down this repo if you have any issues along the way.
You should see hello from moovite
after starting the server.
Now go to /test
and you should see our other component!
We just built a basic server rendered React framework, hopefully you're feeling as excited as I am, but we still have a lot of work to do.
If we look back at our src/pages/index.tsx
component, you can see we have an onClick handler:
<div onClick={() => console.log("hello")}>Hello from moovite</div>;
If you click on this text on the homepage, nothing is logged to the console. So far, we haven't written any code to mount the app again on the client side. Right now we are spitting out html on the server, and that's it.
getServerSideProps
Let's go back to our pages/index.tsx
and replace its contents:
export const getServerSideProps = () => {
return { message: "Hello from the server!" };
};
type Props = {
message: string;
};
const Homepage = ({ message }: Props) => {
return <div onClick={() => console.log("hello")}>{message}</div>;
};
export default Homepage;
If we start the server and load the homepage, we will see Hello from the server
. If we inspect the page, we will also see this props are listed in a script tag in the head
of the page! This is important for our next section. In order to mount the app on the client's browser, it needs this server data.
See the code for this completed section
Client Side Hydration + Routing
In this section, we are going to make it possible to mount the app on the client so that JS interactions work as expected. We are also going to setup routing, so you can link to other pages, without doing a full page refresh.
To get started, we will make a new file in the moovite
folder in the root of the project. context.ts
import { createContext, useContext } from "react";
import { Page } from "./ssr/types";
type SingleRoute = {
path: string;
exact?: boolean;
getComponent: () => any;
};
export const routes: SingleRoute[] = [
{
path: "/",
exact: true,
getComponent: () => import("../src/pages/index"),
},
{
path: "/test",
getComponent: () => import("../src/pages/test"),
},
];
This is the start of the file. It will contain our routes, which is the list of pages our app has. The routes array should be auto generated at the framework level, inside of the vite server. I didn't implement this in the tutorial due to how much we are already covering in this post. In a part 2 post, we would want to solve this. Right now, if you make a new file in the pages
directory, it will not start working until being manually added here.
We need to add the rest of the code to the file below:
type MooviteContextType = {
activePage: Page;
setActivePage: (page: Page) => void;
};
export const MooviteContext = createContext<MooviteContextType>({} as any);
const getServerData = async (to) => {
let res = await fetch(`/data/${to}`);
return await res.json();
};
export const useMoovite = () => {
let { setActivePage } = useContext(MooviteContext);
return {
navigate: async (to: string) => {
let [props, { default: component }] = await Promise.all([
getServerData(to),
routes.find((route) => route.path === to).getComponent(),
]);
setActivePage({ path: to, component, props });
history.pushState(null, "", to);
},
};
};
We are creating a context that will be responsible for setting the active page. It also contains a function called getServerData
which will return the response from getServerSideProps
when attempting to navigate to a new page.
Let's break this down a bit further since this can be confusing if you've never done this before.
User loads the homepage
We hydrate the client and mount React
The user clicks a link that goes to
/test
We have to make a request to the server for its props
We also need to load the
/test
component using a dynamic import statementOnce both of those complete, we can set the active page, and update the browser url
As long as our getServerSideProps
resolves under 100ms, the page change will feel instant to the user. There's also lots of ways to get fancy here. We can load the props and component when a user hovers over a link, which can make it feel even faster.
Hopefully you are getting lots of ideas on how frameworks can be a ton of work, but also how to code for lots of neat features.
At a high level, we are making a useMoovite
context wrapper that exposes a navigate
method that handles all of the work described above. This lets the end user navigate between pages on their own.
Before moving on, we need to make moovite/ssr/types.ts
which will contain a few types that our framework needs to expose:
import { PrismaClient } from "@prisma/client";
type ServerSideArgs = {
prisma: PrismaClient;
};
export type GetServerSideProps = (data: ServerSideArgs) => any;
export type Page = {
path: string;
props: any;
component: any;
};
We define our getServerSideProps function, which gets access to prisma (more on that in the next section) and our Page type.
Let's update our moovite/entry.tsx
to take advantage of our new context, and actually hydrate the app on the client:
import ReactDOM from "react-dom";
import { Page } from "./ssr/types";
import { createElement, useState } from "react";
import { MooviteContext, routes } from "./context";
type Props = {
page?: Page;
};
export const App = ({ page }: Props) => {
let [activePage, setActivePage] = useState(page);
return (
<MooviteContext.Provider value={{ activePage, setActivePage }}>
{createElement(activePage.component, activePage.props)}
</MooviteContext.Provider>
);
};
const hydrate = async () => {
let activeRoute = routes.find(
(route) => route.path === window.location.pathname
);
let { default: component } = await activeRoute.getComponent();
ReactDOM.hydrate(
<App
page={{
props: (window as any)._MOOVITE_PROPS_,
path: window.location.pathname,
component,
}}
/>,
document.getElementById("app")
);
};
//@ts-ignore
if (!import.meta.env.SSR) hydrate();
This might seem like a lot of stuff, but things are only getting easier from here. We have gone through the most complicated parts by now.
First, our App
renders our context provider, and moves the current page into state. This will let us change the page when navigating, as we saw in our context file.
The new stuff is in the hydrate
method.
To keep our framework incredibly efficient, our main entrypoint only contains a routes array with dynamic import statements, and the code seen above.
If we created 200 pages, our JS bundle would have 200 items in the routes array, but it wouldn't be including all 200 components into our bundle. This lets our entry point download fast, because it doesn't contain much code.
To hydrate the app, we need to find the active route. We do this by looping over the routes, and finding the one that matches the current path in the browser url. Then, we wait for that component bundle to be loaded. Once it is, we can now hydrate the app, pass it the props from the script tag in the head, and the component we just loaded!
The final line of logic is exposed by vite. We only want to call hydrate
on the browser, not on the server.
If you start the server, open up http://localhost:3000
.... you will see... the same thing as before.
BUT if you click on the text, you will see the onClick is working, and hello
is logged to the console! We have a fully SSR'd framework, with mounting on the client.
See the code for this completed section
Client Side Routing
That was super cool... but how should we navigate between pages? Let's create moovite/Link.tsx
import { ReactNode } from "react";
import { useMoovite } from "./context";
type Props = {
to: string;
children: ReactNode;
};
export const Link = ({ to, children }: Props) => {
let { navigate } = useMoovite();
return (
<a
href={to}
onClick={(e) => {
e.preventDefault();
navigate(to);
}}
>
{children}
</a>
);
};
If a user has JS disabled, this anchor tag will still work, and trigger a full page load from our server. Once React is mounted, it'll call the navigate method from our context that we created earlier.
Before we can see this in action, take a look back at the context.ts
we created, and you'll see this function
const getServerData = async (to) => {
let res = await fetch(`/data/${to}`);
return await res.json();
};
We need to create a route that grabs the data for a given path.
Let's update our server.ts
to look like this:
import express from "express";
import { createServer as createViteServer } from "vite";
import { serverRenderRoute } from "./moovite/ssr/serverRenderRoute";
import { getServerSideProps } from "./moovite/ssr/getServerSideProps";
async function createServer() {
const app = express();
const vite = await createViteServer({
server: { middlewareMode: "ssr" },
});
app.use(vite.middlewares);
// Add this line
app.use("/data/*", getServerSideProps({ vite }));
app.use("*", serverRenderRoute({ vite }));
app.listen(3000, () => console.log("listening on :3000"));
}
createServer();
All that we added is getServerSideProps
route for the data path. Now let's create moovite/ssr/getServerSideProps.ts
import { RequestHandler } from "express-serve-static-core";
import { ViteDevServer } from "vite";
import { pageLoader } from "./pageLoader";
type Props = {
vite: ViteDevServer;
};
export const getServerSideProps =
({ vite }: Props): RequestHandler =>
async (req, res) => {
const url = req.originalUrl.replace("/data/", "");
let { props } = await pageLoader({
url,
vite,
});
res.send(props);
};
As I promised before, we are getting simpler, and simpler. This function removes /data/
from the url, to get the root path of the component we want. Then it gives it to the pageLoader
we already created, and sends the props back to the client.
Now, let's update our two pages
components:
index.tsx
import { Link } from "../../moovite/Link";
export const getServerSideProps = () => {
return { message: "Hello from the server!" };
};
type Props = {
message: string;
};
const Homepage = ({ message }: Props) => {
return (
<div onClick={() => console.log("hello")}>
{message}
<Link to="/test">Go to test</Link>
</div>
);
};
export default Homepage;
test.tsx
import { Link } from "../../moovite/Link";
const Test = () => {
return <div>Hello from test <Link to="/">Go home</Link></div>;
};
export default Test;
You'll notice the request for data when changing pages, and the client routing is working without a full page load! Another gotcha though: if you hit the browser back button, we didn't tie into that, so the component doesn't change. Yet another thing a production framework needs that we aren't covering.
See the code for this completed section
Adding Prisma
We've covered a ton of stuff. But it wouldn't be complete without showing off a really cool way of loading data on the server. We'll be adding prisma for our data layer, and make it accessible in our getServerSideProps methods. It'll let us grab data without making api routes, graphql servers, or anything else.
Run this in the root of the project:
npx prisma init
Open prisma.schema
and add this to the end:
model Message {
id Int @id @default(autoincrement())
text String
}
Instead of hard coding our message on the homepage, we will pull it in from a postgres database powered by prisma.
npx prisma generate
This command will generate our prisma client based on our data model. If you're new to prisma, you can pull down an existing database structure, and the schema file will be auto populated, which can make switching to it a breeze.
Now, let's create prisma/seed.ts
and seed a message into the database:
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export async function seed() {
await prisma.message.create({ data: { text: "Welcome from prisma!" } });
}
My favorite part of prisma is the type safety. If we put the wrong name inside the data object, or change our prisma schema, TypeScript will alert us immediately that things are broken.
Now we can update our pageLoader.ts
to support prisma! Add this to the top of it:
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
Then change the line before the return statement, so that prisma is passed in:
if (getServerSideProps) props = await getServerSideProps({ prisma });
Update the top portion of our pages/index.tsx
component:
import { Link } from "../../moovite/Link";
import { GetServerSideProps } from "../../moovite/ssr/types";
export const getServerSideProps: GetServerSideProps = async ({ prisma }) => {
let firstMessage = await prisma.message.findFirst();
return { message: firstMessage.text };
};
By adding our GetServerSideProps
type that we created earlier, prisma is fully type safe above our components too!
The last step before testing out our prisma additions is to connect to a postgres database. One easy way is using Docker:
docker run -d -p 5433:5432 -e POSTGRES_PASSWORD=root -d postgres
If you are unsure of how to setup docker / postgres, check out Supabase, to get access to one quickly:
Now, back to the tutorial.
Open up the .env
file created from prisma init, and replace the DATABASE_URL line with:
DATABASE_URL="postgresql://postgres:root@localhost:5433/mydb?schema=public"
Finally, we can run npx prisma db push
to update the database with our prisma schema. Then npx prisma db seed --preview-feature
to seed it.
Start up the server a final time with npm start
and the homepage should show Welcome from prisma!
See the code for this completed section
Conclusion
Phew... that was a lot of concepts!
I hope this gives a really cool intro into how you can build your own framework and just how much work it is to make something like this.
We only scratched the surface of what's possible. I tried my best to call out things that need to be changed / fixed / added to make this a production ready platform.
If you've got any feedback or questions, please reach out to us @frontndblog on Twitter!