Skip to main content

Server-Side Rendering (SSR)

Many frameworks offer server-side rendering (SSR) support, which is the ability to render a page on the server-side and then send this information to the client as HTML. SSR provides many benefits such as:

  • Better Search Engine Optimization (SEO) support.
  • Resilience in the face of issues such as network connectivity, ad blockers, and obstacles to loading JavaScript.
  • The ability to incrementally render data without forcing the user to wait for the entire page to load.

The last benefit is where Connect-ES fits in. Consider a scenario where your application needs to make many API requests for data that rarely changes. Using Connect-ES with SSR allows you to perform these fetches on the server, significantly reducing the time to First Contentful Paint, which is a metric that measures the time from when the page starts loading to when any part of the page's content is rendered on the screen

The main thing to be aware of when dealing with SSR is that any data that crosses a network boundary (i.e. from server to client) must typically be JSON-serializable by JSON.stringify and JSON.parse, which only supports plain Objects, Array, Number, String, and Boolean.

Protobuf message are JSON-serializable out of the box if they:

React Server components with Next.js use a more capable serialization logic. In addition to regular JSON-serializable types, it also supports BigInt, Infinity, NaN, and Uint8Array.

Protobuf messages are serializable by React out of the box if they:

If your Protobuf messages are not serializable, there's a simple solution: You convert them to JSON with the toJson function from @bufbuild/protobuf. You can either use the JSON objects (the JSON types feature will be helpful), or parse them again with fromJson.

Let's walk through a few examples in various setups:

Next.js

getServerSideProps

The function getServerSideProps is invoked at request time and is used to fetch data when a page is requested. The returned data is then passed to your component in props. getServiceSideProps must return JSON.

In the following example, we use the proto3 message SayResponse, which is JSON serializable:

import type { InferGetServerSidePropsType } from 'next'
import { create } from '@bufbuild/protobuf';
import { SayRequestSchema } from "@buf/connectrpc_eliza.bufbuild_es/connectrpc/eliza/v1/eliza_pb";

export const getServerSideProps = (async () => {
const sayRequest = create(SayRequestSchema, {
sentence: "hi",
});
return { props: { sayRequest } }
});

export default function Page({
sayRequest,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
return (
<main>
<p>{sayRequest.sentence}</p>
</main>
)
}

The following example uses the message Payload, which is not JSON serializable. We convert it to JSON to cross the boundary:

import type { InferGetServerSidePropsType } from 'next'
import { create, toJson, fromJson } from '@bufbuild/protobuf';
import { PayloadSchema } from "./gen/payload_pb";

export const getServerSideProps = (async () => {
const payload = create(PayloadSchema, {
largeNumber: 123n,
});
return { props: {
payloadJson: toJson(payload, PayloadSchema)
}}
});

export default function Page({
payloadJson,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
const payload = fromJson(PayloadSchema, payloadJson);
return (
<main>
<p>{payload.largeNumber.toString()}</p>
</main>
)
}

The same principle applies to the function getStaticProps. For a working example of getServerSideProps with Next.js, check out the Next.js project in our examples-es repo.

React Server Components

Version 13 of Next.js adds the new App Router, which uses React Server Components. By default, everything is rendered on the server. In the following example, the Protobuf message we use never crosses the boundary, and there are no limitations of serialization to consider:

import { create } from "@bufbuild/protobuf";
import { PayloadSchema } from "./gen/payload_pb";

export default function Page() {
const payload = create(PayloadSchema, {
largeNumber: 123n,
});
return (
<main>
<p>{payload.largeNumber.toString()}</p>
</main>
)
}

Let's add a component that runs in the browser:

"use client";

import { Payload } from "./gen/payload_pb";

export default function Client({ payload }: { payload: Payload }) {
return (
<div>
<h5>Payload rendered on the client</h5>
<p>{payload.largeNumber.toString()}</p>
</div>
);
}
import { create } from "@bufbuild/protobuf";
import { PayloadSchema } from "./gen/payload_pb";
import Client from "./client";

export default function Page() {
const payload = create(PayloadSchema, {
largeNumber: 123n,
});
return (
<main>
<Client payload={payload}></Client>
</main>
);
}

When the page is rendered on the server, the props for the component Client are serialized, and the component is rendered in the browser. The payload prop crosses the boundary, but since it's a proto3 message, React can serialize and hydrate it without issues. It handles the BigInt value 123n correctly.

note

If you use Next.js earlier than version 15, be careful with Protobuf bytes fields. A Uint8Array turns into a plain Array when crossing the boundary. This issue is fixed with Next.js 15.

For a working example of React Server Components with Next.js, check out the Next.js project in our examples-es repo.

Svelte

fetch

Svelte's load functions can run on the server, and receive a fetch function that inherits cookies and can make relative requests. We can pass fetch to a transport to make use of this data:

import type { PageServerLoad } from "./$types";
import { createConnectTransport } from "@connectrpc/connect-web";

export const load: PageServerLoad = async ({ fetch }) => {
const transport = createConnectTransport({
baseUrl: "/api",
fetch,
});
// call RPCs here
return {};
};

Server load

Server load functions always run on the server. The data they return is serialized by Svelte, embedded into the document sent to the web browser, and hydrated on page load. Svelte 5 uses devalue to serialize data, which fully supports BigInt, Infinity, NaN, and Uint8Array.

As long as you use the "proto3" syntax (which does not require the prototype chain to track field presence) or the Editions equivalent, you can safely return Protobuf messages from a server load function.

Universal load

Universal load functions run on the server when the page is first visited. Instead of serializing the data that the function returns, Svelte serializes the responses from any fetch requests that the function makes. It transparently hydrates the responses when it runs the load function again in the browser, avoiding additional network requests.

You can safely return any Protobuf message from a universal load function.

By default, Svelte removes all response headers from serialized fetch responses. You can configure the behavior with a hooks.server.ts file. You must allow at least the Content-Type header for Connect:

import type { Handle } from "@sveltejs/kit";

export const handle: Handle = async ({ event, resolve }) =>
await resolve(event, {
filterSerializedResponseHeaders: (name) => name === "content-type",
});

For full working examples of both universal and server load functions check out the Svelte project in our examples-es repo.