Building frameworky things with vanilla react (SSR, file-based routing and server-side data fetching)

Building frameworky things with vanilla react (SSR, file-based routing and server-side data fetching)

Server-side rendering involves rendering a web page on the server before sending it to the client, which can improve the initial load time and SEO of the application. File-based routing involves mapping URLs to specific components or pages based on the file structure of the application, which can simplify the routing process and improve maintainability.

Let's make something that next.js gives us out of the box. Server-side rendering and file-based routing and understand the underneath implementation of these things. These things won't be a finished product because it's built quickly, but they will give you an idea of most of the things used to build something similar. The actual implementation has more things on top of what we are doing, like extra validation and features.

Here you can find a basic setup that has to react setup + router implementation and the logic to show products that involve data fetching and rendering it.

├── package.json
├── App.tsx
├── index.html
├── index.tsx
├── Link.tsx
├── Product.tsx // to show individual product
├── Products.tsx // to show all products
├── Route.tsx // router implementaion
├── Router.tsx // router implementaion
└── tsconfig.json

Let's start some fun stuff now.


We will first start by adding server-side rendering to the application.

let's create a file server.tsx and add logic to server the App component of our react from an express server. We will use a function by React called renderToString

So basically this converts a react element to Html and should be used with server-side applications like Express to send HTML as a response.

The server file should look something like this:

import React from "react";

// to create a backend express app
import express from "express";

/**
 * Render a React element to its initial HTML. This should only be used on the server.
 * React will return an HTML string. You can use this method to generate HTML on the server
 * and send the markup down on the initial request for faster page loads and to allow search
 * engines to crawl your pages for SEO purposes.
 *
 * If you call `ReactDOMClient.hydrateRoot()` on a node that already has this server-rendered markup,
 * React will preserve it and only attach event handlers, allowing you
 * to have a very performant first-load experience.
 */
// export function renderToString(element: ReactElement): string;
import { renderToString } from "react-dom/server";

// react component that will be sent as html from the expres app as a response.
import { App } from "./App";

const app = express();

app.get("/", (req, res) => {
  // converting react component to html string
  const html = renderToString(<App />);
  res.send(`
     <!DOCTYPE html>
     <html>
          <head>
          <title>React SSR</title>
          </head>
          <body>
          <div id="root">${html}</div>
          </body>
     </html>
     `);
});

app.listen(3000, () => {
  console.log("running on port 3000");
});

Now as soon as we run this app which looks perfect to us it will break. Why? and How?

Screenshot from 2023-07-15 19-34-14.png

ReferenceError: window is not defined

Why?

Because the server doesn't have any window.

As we are using our router it depends upon Window to do a lot of stuff. As we do not have it in server runtime we will have to make some adjustments for our router to tell it when can it use the window and when not.

export const RouterContext = createContext({
  path: "",
  pushState: (path: string) => {},
  replaceState: (path: string) => {},
});

// making adjustment for this do work from server side as well
const canWindow = () => typeof window !== "undefined";

export const Router = ({
  children,
  initialPath,
}: {
  initialPath: string;
  children: React.ReactNode;
}) => {
  const [path, setPath] = React.useState<string>(
    canWindow() ? window.location.pathname : initialPath
  );

    // other router helper methods...

  return (
    <RouterContext.Provider value={{ path, pushState, replaceState }}>
      {children}
    </RouterContext.Provider>
  );
};

And path this initialPath to the index.ts and app.tsx

For this intialPath in server side, we will just send the endpoint path as the initialPath and it should satisfy the router.tsx

Even after the changes we see the output below (blank screen) why?

Because we havent use react here this is the plain jsx converted to html and send from the express app. And hence we will need to import all the js that react produces by adding the js file to script of the HTML send from the endpoint.

screely-1689438145483.png

import { readdirSync } from "fs";
import { join } from "path";

// serving static files from dist folder
app.use(express.static("dist"));

// handling all the routes
app.get("*", (req, res) => {
  // converting react component to html string using renderToString and sending it as a response.
  // passing the initial Path from here which would be similar to the url path.
  const html = renderToString(<App initialPath={req.path} />);
  res.send(`
     <!DOCTYPE html>
     <html>
          <head>
          <title>React SSR</title>
          </head>
          <body>
          <div id="root">${html}</div>
          // client.js is the file that will be sent as a response from the server which is bascially all the react scripts.
          <script src="client.js"></script>
          </body>
     </html>
     `);
});

As we were only serving the HTML all the react code was not sent and hence we read the client.js the file that is created by react from all the react code we have written. Since this also has all the logic regarding the data fetching this should work fine for us now!

screely-1689493528497.png

And yes it does!

So now at least we have server full routing and rendering in react. But still, the data fetching part is missing we are still doing that in react.

What is the best place and time to fetch data for a react component?

Is it inside the react component? Actually fetching data should be done as soon as the server endpoint is hit before even rendering anything from the component.

So to do data fetching server side we will have to think of a way to extract the fetch logic which would be exported from the components and run it on the server side.

So as we come to a problem of data fetching I think we need file system routing why?

Because we can get the react component as a default export and any exported function for data fetching. Fetch from the exported function which is responsible for data fetching and render default export as it would be the component for that particular route.

├── package.json
├── App.tsx
├── index.html
├── index.tsx
├── Link.tsx
├── pages
│   ├── product.tsx
│   └── products.tsx
├── Route.tsx
├── Router.tsx
└── tsconfig.json

Hence we will move the files to a folder called pages and will make some corresponding changes to the server file to fetch all the files from products and treat each file as a new route.

Let's make some changes in react component where we will export the component as default and also a getProps function which fetches data/props on the server side and pass it as props to the component so here we will be getting our data fetched on the server side.

Here is an example of how we will change our existing component.

const getData = async () => {
  const response = await fetch("<https://fakestoreapi.com/products>");
  const data = await response.json();
  return data;
};

const Products = ({ initialData = [] }) => {
  const [products, setProducts] = React.useState<any>(initialData);

  return (
    <div style={{ margin: "6rem" }}>
      <h1>Products</h1>
      <div>
        {products.map((product) => (
          <Link to={`/product?id=${product.id}`}>
            <div
              style={{
                background: "white",
                padding: "1rem",
                border: "2px grey solid",
                borderRadius: "15px",
                marginBottom: "5px",
              }}
              key={product.id}>
              {product.title}
            </div>
          </Link>
        ))}
      </div>
    </div>
  );
};

export default Products;

export const getProps = async () => {
  const data = await getData();
  return {
    initialData: data,
  };
};

So here you can see we are exporting component as default and getProps to fetch data server side.

We will also need to change things on the server side to fetch props and pass them as props to the components. We moved all our components to pages file so we will make changes to have file-based routing.

// getting all the pages from pages folder and storing them in an array with thier names to pass as routes
const pages = readdirSync(join(process.cwd(), "pages")).map(
  (p) => p.split(".")[0]
);

// handling all the routes for page
pages.forEach((page) => {
  // creating a route for each page
  app.get(`/${page}`, async (req, res) => {
    // importing the page component and fetching and passing props if exported from the component
    const module = await import(`./pages/${page}`);
    const Component = module.default;
    const props = module.getProps
      ? await module.getProps({ query: req.query })
      : {};
    const html = renderToString(<Component {...props} />);

    res.send(`
     <!DOCTYPE html>
     <html>
          <head>
          <title>React SSR</title>
          </head>
          <body>
          <div id="root">${html}</div>
          </body>
     </html>
     `);
  });
});

To implement file-based routing we fetch all the components from the folder page. And create a route for each file name. We also take the default export as a component and common getProps function to fetch server-side data.

Here we are using readToString by react but it is not the best way to render and send HTML to client. There is also a function provided by react called renderToPipeableStream we will implement the same thing with it as its asynchronous unline readToString.

Difference?

renderToString:

  • The renderToString function takes a React component and returns its HTML representation as a string.

  • It is a synchronous function that blocks the server's execution until the rendering is complete.

  • It is commonly used in server environments where the response is directly sent as a string to the client.

renderToPipeableStream:

  • The renderToPipeableStream function also takes a React component, but it returns a readable stream that emits the HTML chunks as they become available.

  • It is an asynchronous function that enables streaming the HTML response while it's being generated.

  • It is useful for scenarios where the server needs to stream large responses or integrate with other streaming technologies.

Both renderToString and renderToPipeableStream accomplish server-side rendering, but the choice between them depends on the specific requirements of your application. If you need a synchronous, blocking rendering approach, renderToString is appropriate. On the other hand, if you want to stream the response and have more control over how it is sent, renderToPipeableStream is the better option.

So after implementing the renderToPipeableStream our server.tsx will look something like this.

// handling all the routes for page
pages.forEach((page) => {
  // creating a route for each page
  app.get(`/${page}`, async (req, res) => {
    // importing the page component and fetching and passing props if exported from the component
    const module = await import(`./pages/${page}`);
    const Component = module.default;
    const props = module.getProps
      ? await module.getProps({ query: req.query })
      : {};
    const html = <Component {...props} />;
    const { pipe } = renderToPipeableStream(html);
    pipe(res);
  });
});