← Back to homepage

How I Built Server-Side Rendering (SSR) in My Own Framework

Valtteri Savonen
Valtteri Savonen
Development

This is a straightforward breakdown of how SSR works in Landrr.js, my own fullstack React framework.

Goal for the framework is to take some of my favorite ideas, by me and others to one framework. Also to make building landing pages easier and faster.

1. The need for Server Side Rendering

Why do we need SSR for websites? Answer to that is very easy. Modern websites get bigger and more UI-heavy day by day. Goal is to reduce initial load time so users can see content faster and crawlers see your great SEO more easily.

2. My setup for Landrr.js is pretty simple

The setup starts with two separate files:

src/entry-server.tsx // used by Node.js -> serversrc/entry-client.tsx // used in the browser -> client

Each has a clear responsibility:

  • The server entry renders the React app into an HTML string

  • The client entry hydrates that HTML and enables interactivity

This separation defines the boundary between server and browser and allows us to have the benefits of SSR.

3. Rendering HTML on the Server

When a request comes in, the server renders the React tree using renderToString. This is part of the official react-dom package.

export function render(url: string, initialRouteData = {}) {
  const headContext = createHeadContext();

  const html = renderToString(
    <StrictMode>
      <HeadProvider value={headContext}>
        <StaticRouter location={url}>
          <App initialRouteData={initialRouteData} />
        </StaticRouter>
      </HeadProvider>
    </StrictMode>
  );

  const head = renderHeadToString(headContext.heads);
  return { html, head };
}

Key points:

  • renderToString

    produces static HTML from React components. In real production apps streaming SSR is preferred for performance and convenience. See

    renderToPipeableStream

    for more.

  • StaticRouter

    is used because there is no browser environment because we are on the server

  • A head context collects <title> and <meta> tags during rendering

At the end of this step, the server has:

  • the full rendered HTML that will be sent to browser

  • the corresponding <head> content

4. Loading Data on the Server

Each page can optionally export a function called getServerData:

export async function getServerData() {
  return {
    renderedAt: new Date().toISOString(),
  };
}

If this function exists, it is executed before rendering.

The runtime handles it like this:

export async function runServerDataLoader(routeModule, ctx) {
  if (!routeModule.getServerData) return {};

  const data = await routeModule.getServerData(ctx);
  safeSerialize(data);
  return data;
}
  • If no function is exported, nothing runs

  • The returned data is passed into the React app as props (this is like Next.js Pages-router has it and i love it!)

  • The ctx object contains request-related information (URL, params, etc.)

5. Injecting into the HTML Template

The base HTML file includes placeholders:

<head>
  <!--head-tags-->
</head>
<body>
  <div id="app"><!--ssr-outlet--></div>
  <!--app-data-->
</body>

The server replaces these values:

const html = template
  .replace("<!--head-tags-->", rendered.head)
  .replace("<!--ssr-outlet-->", rendered.html)
  .replace(
    "<!--app-data-->",
    `<script>window.__LANDRR_DATA__=${safeSerialize(routeData)}</script>`
  );

This produces a complete HTML document that includes:

  • the rendered React markup

  • head metadata

  • serialized data for the client

6. Hydration in the Browser

After the HTML is delivered, the client entry takes over:

hydrateRoot(
  document.getElementById("app")!,
  <StrictMode>
    <BrowserRouter>
      <App initialRouteData={window.__LANDRR_DATA__ ?? {}} />
    </BrowserRouter>
  </StrictMode>
);
  • hydrateRoot attaches React to the existing DOM

  • It avoids full re-rendering by reusing existing DOM, but may update mismatches

  • The initial data is read from window.__LANDRR_DATA__ which is just a temporary store for server data during hydration

This allows the client to reuse the same data the server used, reducing mismatches. Also makes client UI interactive for example button can be clicked.

7. Safe Serialization

Because server data is injected into a <script> tag, it must be validated and escaped:

  export function safeSerialize(value: unknown): string {
  validate(value);

   return JSON.stringify(value)
    .replace(/</g, "\\u003c")
    .replace(/\u2028/g, "\\u2028")
    .replace(/\u2029/g, "\\u2029");
}

This prevents:

  • invalid data structures

  • script injection issues

  • parsing edge cases

8. Request Flow

Putting it all together:

Request arrives at server
      ↓
Route is resolved
      ↓
getServerData() runs (if defined)
      ↓
React app is rendered to HTML
      ↓
HTML is injected into template
      ↓
Response is sent to browser
      ↓
Client loads and hydrates

Summary

The implementation follows a few simple rules:

  • Data loading happens explicitly through getServerData -> easy way to manage and define server stuff

  • Rendering is handled by React on both server and client -> core React functionality

  • The server sends fully rendered HTML -> crawlers see instantly what page has and SEO is better because crawlers receive fully rendered HTML immediately

  • The browser hydrates using the same data

Closing words

Landrr.js is currently very early and for a long time lacked any kind of direction so codebase is a mess. This project is not production ready and is in some parts heavily outdated. Please do not use this in any serious production project. Current plan for the future is to build this on my freetime. This is in no active development in any way. But i'm tempted about React Server Components so that might be the next step. But yeah, thanks for reading!!Please let me know if any ideas or feedback arose. Or if i did something wrong.landrr.js:

https://github.com/valtterisa/landrr.js

more of me:

https://valtterisavonen.fi