DEV Community

Cover image for Guide to server-side rendering with Deno and React
Oluyemi for Sanity.io

Posted on • Originally published at sanity.io

Guide to server-side rendering with Deno and React

Summary

In this tutorial, we will take a look at server-side rendering with Deno and React. To get a proper hang of the fundamental concepts, we will start by discovering what Deno is, and how it compares to Node in terms of security, package management, and so on.

Introduction

"Using Node is like nails on a chalkboard."

While Node is still considered a great server-side JavaScript runtime and used by a large community of Software Engineers, even its creator admits that it leaves a lot to be desired. That's why he created Deno.js.

What is Deno?

As stated on its official website, Deno is a simple and secured runtime for JavaScript and TypeScript. According to Ryan, it was created to fix some of the design mistakes made when Node was created. It is gaining popularity and gradually getting adopted by the JavaScript and TypeScript community.

Why Deno?

There are quite a few reasons to choose Deno over Node.js.

  • The first is package management. With Node.js, packages dependencies are declared in a package.json file. The packages are then downloaded to the node_modules folder. This can make the project bloated, unwieldy, and sometimes difficult to manage. Deno takes a different approach to handle package dependencies. In Deno, package dependencies are installed directly from a provided URL and stored centrally. Additionally, every package is cached on the hard drive after installation which means they don’t have to be re-downloaded for subsequent projects.
  • The second key advantage is security. By default, Deno takes an opt-in approach where you have to specify the permissions for the application. This means that Deno scripts cannot (for example) access the hard drive, environment, or open network connections without permission. This not only prevents unwanted side-effects in our application but also reduces the impact of potentially malicious packages on our application.
  • Deno also provides TypeScript support by default which means that one can take advantage of all the benefits of static typing without any additional configuration.

What is server-side rendering?

Server-side rendering (SSR) is the process of rendering web pages on a server and passing them to the browser instead of rendering them in the browser (client-side).

This has the advantage of making applications more performant as most of the resource-intensive operations are handled server-side. It also makes Single Page Applications (SPA) more optimized for SEO as the SSR content can be crawled by search engine bots.

What we will build

In this tutorial, we will build a movie application to show some bestselling movies and A-list actors. Sanity.io will be used for the backend to manage contents, which includes creating, editing, and updating data for the project. While Deno and React will be used for the front end and server-side rendering respectively. Tailwind CSS will be used to style the application UI.

Gif image of the movie app

Prerequisites

To keep up with the concepts that will be introduced in this article, you will need a fair grasp of ES6. You will also need to have a basic understanding of React and TypeScript.

Additionally, you will also need to have the following installed locally. Please click on each one for instructions on how to download:

  1. Deno
  2. Node Package Manager (NPM)
  3. Sanity CLI
  4. A code editor of your choice. For example, VS Code.

Getting started

To get started, create a new directory in your terminal.

mkdir sanity-deno

cd sanity-deno

Enter fullscreen mode Exit fullscreen mode

In the sanity-deno directory, create a new directory called frontend. This will hold the components for our React UI along with the code for handling SSR using Deno.

mkdir frontend

Enter fullscreen mode Exit fullscreen mode

In the frontend directory, create two new directories, ssr and components.

mkdir frontend/ssr frontend/components
Enter fullscreen mode Exit fullscreen mode

Setting up SSR

In the ssr directory, create a new file named server.tsx to hold our server side code.

cd frontend/ssr

touch server.tsx
Enter fullscreen mode Exit fullscreen mode

For our Deno server, we are using the Oak middleware framework, which is a framework for Deno's HTTP server. It is similar to Koa and Express and the most popular choice for building web applications with Deno. In server.tsx, add the following code:

// frontend/ssr/server.tsx

import { Application, Router } from 'https://deno.land/x/oak@v7.3.0/mod.ts';

const app = new Application();
const port: number = 8000;

const router = new Router();
router.get("/", (context) => {
    context.response.body =
        `<!DOCTYPE html>
                 <html lang="en">
                 <head>
               <meta charset="UTF-8">
               <meta name="viewport" content="width=device-width, initial-scale=1.0">
                 <link href="https://cdn.jsdelivr.net/npm/tailwindcss/dist/tailwind.min.css" rel="stylesheet">
               <title>Sanity <-> Deno</title>
                 </head>
                 <body>
               <div id="root">
           <h1> Deno and React with Sanity</h1>
                 </div>
                 </body>
                 </html>`;
});

app.use(router.routes());
app.use(router.allowedMethods());

app.listen({port});
console.log(`server is running on port: ${port}`);
Enter fullscreen mode Exit fullscreen mode

Using the Application and Router modules imported from the Oak framework, we create a new application that listens to requests on port 8000 of the local workstation. We've also added an index route that returns the HTML content to be displayed by the browser. Tailwind is added to the project via the link declared in the head of the HTML element.

With this in place we can test our deno server. Run the following command (within the ssr directory).

deno run --allow-net server.tsx
Enter fullscreen mode Exit fullscreen mode

In this command, we specify server.ts as the entry point for the deno application. We also use the --allow-net command to grant the application permission to access network connections.

Navigate to http://localhost:8000 and you will see:

Deno application homepage

Stop your application for now by pressing Ctrl + C.

Adding other dependencies

In the frontend directory, create a file named dep.ts

touch frontend/dep.ts 
Enter fullscreen mode Exit fullscreen mode

The dep.ts file will contain the frontend project dependencies. This is in line with the deno convention for managing dependencies. Our application will import the following packages:

  1. React, ReactDOM and ReactDOMServer for our React UI.
  2. React Router to handle routing between components
  3. The Sanity image-url builder

Add the following dependencies to dep.ts

// @deno-types="https://denopkg.com/soremwar/deno_types/react/v16.13.1/react.d.ts"
import React from "https://jspm.dev/react@17.0.2";
// @deno-types="https://denopkg.com/soremwar/deno_types/react-dom/v16.13.1/server.d.ts"
import ReactDOMServer from "https://jspm.dev/react-dom@17.0.2/server";
// @deno-types="https://denopkg.com/soremwar/deno_types/react-dom/v16.13.1/react-dom.d.ts"
import ReactDOM from "https://jspm.dev/react-dom@17.0.2";
import imageURLBuilder from "https://cdn.skypack.dev/@sanity/image-url@0.140.22";
export {
  BrowserRouter,
  StaticRouter,
  Link,
  Route,
  Switch,
} from "https://cdn.skypack.dev/react-router-dom";
export { React, ReactDOM, ReactDOMServer, imageURLBuilder };
Enter fullscreen mode Exit fullscreen mode

We use the @deno-types annotation to specify a URL where the React and ReactDOMServer types can be found. You can read more about providing types when importing here.

Creating the app component

Next, create the App component. In the frontend/components directory, create a file named App.tsx.

In App.tsx add the following:

// frontend/components/App.tsx

import { React } from "../dep.ts";

const App = () => <h1> App Component for Deno and React with Sanity</h1>;

export default App;
Enter fullscreen mode Exit fullscreen mode

Here, we are still returning a h1 tag but this will serve as the entry point for React as we build further.

Rendering components via SSR

At the moment, we're returning HTML from the deno server but we also need to render our react components server side. To do this, update ssr/server.tsx to match the following:

// frontend/ssr/server.tsx

import { Application, Router } from 'https://deno.land/x/oak@v7.3.0/mod.ts';
import { ReactDOMServer, React } from "../dep.ts";
import App from "../components/App.tsx";

const app = new Application();
const port: number = 8000;

const router = new Router();
router.get("/", (context) => {
  context.response.body = `
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <link href="https://cdn.jsdelivr.net/npm/tailwindcss/dist/tailwind.min.css" rel="stylesheet">
    <title>Sanity <-> Deno</title>
</head>
<body >
    <div id="root">${ReactDOMServer.renderToString(<App />)}
    </div>
</body>
</html>`;
});

app.use(router.routes());
app.use(router.allowedMethods());

app.listen({ port });
console.log(`server is running on port: ${port}`);
Enter fullscreen mode Exit fullscreen mode

Using the renderToString function, we are able to render our App component in the body of the HTML returned by our deno server.

Restart the application using the npm run start command and you will see the new body is reflected.

Content of AppComponent

With this in place, we actually have our SSR set up. Let's add some content to our application by building the backend with Sanity.

Setting up the backend

In the sanity-deno directory, create a new directory called backend. Initialize a new Sanity project in the backend directory.

mkdir sanity-deno/backend

cd backend 

sanity init
Enter fullscreen mode Exit fullscreen mode

You will be  prompted to provide some information. Proceed as follows:

  1. Select the Create new project option
  2. Name the project react_deno-backend
  3. Use the default dataset configuration (press Y)
  4. Select the project output path (by default it would be the backend directory)
  5. Select the movie project (schema + sample data) option. Using the arrow keys, navigate to that option and press enter when it turns blue.
  6. Upload a sampling of sci-fi movies to your dataset on the hosted backend (press Y)

The Sanity CLI will bootstrap a project from the movie template, link the needed dependencies and populate the backend with some science fiction movies. Once the process is completed, you can run your studio. By default, the studio runs at http://localhost:3333.

sanity start
Enter fullscreen mode Exit fullscreen mode

Your studio will be similar to the screenshot below.

Sanity studio

To connect our frontend application with Sanity, we need the projectId and dataset used for our Sanity project. You can find this in the api node of backend/sanity.json. Make a note of it as we will be using it later on.

We also need to enable CORS on our backend to ensure that it honors requests made by the application. By default, the only host that can connect to the project API is the sanity studio (http://localhost:3333). Before we can make requests to the API, we need to add the host for our application (http://localhost:8000/) to the permitted origins. To do this, open your Sanity Content Studio. This will show you all the Sanity projects you have initialized. Select the project we are working on (react_deno-backend) and click on the Settings tab. Click on the API menu option. In the  CORS Origins section, click the Add new origin button.  In the form that is displayed, type http://localhost:8000 as the origin. Click the Add new origin button to save the changes made.

Creating a client for sanity studio

We need a client that will help us connect and retrieve data from our Sanity studio. In the frontend directory, create a new file called SanityAPI.ts.

touch frontend/SanityAPI.ts
Enter fullscreen mode Exit fullscreen mode

Rune Botten created a sample client we can use with deno here. We will use this as a foundation and make some slight modifications to suit our use case. Add the following code to frontend/SanityAPI.ts.

// frontend/SanityAPI.ts

type SanityClientOptions = {
  projectId: string;
  dataset: string;
    apiVersion: string;
  token?: string;
  useCdn?: boolean;
};

type QueryParameters = Record<string, string | number>;

const sanityCredentials = {
    projectId: "INSERT_YOUR_PROJECT_ID_HERE",
    dataset: "production",
};

const sanityClient = (options: SanityClientOptions) => {
  const { useCdn, projectId, dataset, token, apiVersion } = options;
  const hasToken = token && token.length > 0;
  const baseHost = useCdn && !hasToken ? "apicdn.sanity.io" : "api.sanity.io";
  const endpoint = `https://${projectId}.${baseHost}/v${apiVersion}/data/query/${dataset}`;

  // Parse JSON and throw on bad responses
  const responseHandler = (response: Response) => {
    if (response.status >= 400) {
      throw new Error([response.status, response.statusText].join(" "));
    }
    return response.json();
  };

  // We need to prefix groq query params with `$` and quote the strings
  const transformedParams = (parameters: QueryParameters) =>
    Object.keys(parameters).reduce<QueryParameters>((prev, key) => {
      prev[`$${key}`] = JSON.stringify(parameters[key]);
      return prev;
    }, {});

  return {
    fetch: async (query: string, parameters?: QueryParameters) => {
      const urlParams = new URLSearchParams({
        query,
        ...(parameters && transformedParams(parameters)),
      });

      const url = new URL([endpoint, urlParams].join("?"));
      const request = new Request(url.toString());

      if (hasToken) {
        request.headers.set("Authorization", `Bearer ${token}`);
      }

      return (
        fetch(request)
          .then(responseHandler)
          // The query results are in the `result` property
          .then((json) => json.result)
      );
    },
  };
};

export const runQuery = async (
  query: string,
  callback: (json: any[]) => void
) => {
  const client = sanityClient({
    ...sanityCredentials,
    useCdn: false,
        apiVersion: "2021-03-25"
  });
  await client.fetch(query).then(callback);
};

export const urlFor = (source: any) =>
  imageURLBuilder(sanityCredentials).image(source);
Enter fullscreen mode Exit fullscreen mode

Note the runQuery function. We provide a GROQ query as the first parameter, the second is a callback function to be executed on the response to the provided query. This function will be called anytime we need to pull data from our backend.

We also added a helper function named urlFor which will be used to generate image URLs for images hosted on Sanity.

Lastly, don't forget to replace the INSERT_YOUR_PROJECT_ID_HERE placeholder with your projectId as obtained earlier.

Building the components

Our application will display the movies and actors saved on our Sanity Content Lake. In the components directory, create a directory named movie to hold movie-related components and another named actor to hold actor-related components.

mkdir frontend/components/actor frontend/components/movie
Enter fullscreen mode Exit fullscreen mode

In the movie directory, create two files named Movies.tsx and Movie.tsx. In the Movies.tsx, add the following:

// frontend/components/movie/Movies.tsx

import { React } from "../../dep.ts";
import Movie from "./Movie.tsx";
import { runQuery } from "../../SanityAPI.ts";

const { useState, useEffect } = React;
const Movies = () => {
  const [movies, setMovies] = useState<any>([]);
  const updateMovies = (movies: any[]) => {
    setMovies(movies);
  };
  useEffect(() => {
    const query = `*[_type == 'movie']{_id, title, overview, releaseDate, poster}`;
    runQuery(query, updateMovies);
  }, []);

  return (
    <div className="container mx-auto px-6">
      <h3 className="text-gray-700 text-2xl font-medium">
        Bestselling Sci-fi Movies
      </h3>
      <div className="p-10 grid grid-cols-1 sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-1 xl:grid-cols-3 gap-5">
        {movies.map((movie: any) => (
          <Movie key={movie._id} {...movie} />
        ))}
      </div>
    </div>
  );
};

export default Movies;
Enter fullscreen mode Exit fullscreen mode

In this component, we make a GROQ request to the Sanity API using the helper client we created earlier. In our query, we request for the id, title, overview, release date and poster image for all the movies on our backend.

Next, add the following to Movie.tsx.

// frontend/components/movie/Movie.tsx

import { React } from "../../dep.ts";
import { urlFor } from "../../SanityAPI.ts";

const Movie:React.FC<any> = ({ title, overview, releaseDate, poster }) => {
  return (
      <div className="max-w-xs rounded overflow-hidden shadow-lg my-2">
        <img
          src={`${urlFor(poster.asset._ref)}`}
          alt={title}
        />
        <div className="px-6 py-4">
          <h3 className="text-xl mb-2 text-center">{title}</h3>
          <p className="text-md justify-start">
            {overview[0].children[0].text}
          </p>
        </div>
        <div className="align-bottom">
          <p className="text-gray text-center text-xs font-medium my-5">
            Released on {new Date(releaseDate).toDateString()}
          </p>
        </div>
      </div>
  );
};

export default Movie;
Enter fullscreen mode Exit fullscreen mode

Using Tailwind, we design a card that holds the poster image for the movie with the image URL generated by the urlFor helper function we declared earlier. We are also displaying the other details provided by the Movies component.

As we did for the movies, create two files named Actor.tsx and Actors.tsx in the actor directory. Add the following to Actor.tsx

// frontend/components/actor/Actor.tsx

import { React } from "../../dep.ts";
import { urlFor } from "../../SanityAPI.ts";

const Actor: React.FC<any> = ({ name, image }) => {
  const defaultImageURL =
    "https://images.vexels.com/media/users/3/140384/isolated/preview/fa2513b856a0c96691ae3c5c39629f31-girl-profile-avatar-1-by-vexels.png";
  return (
    <>
      <div className="max-w-xs rounded overflow-hidden shadow-lg my-2">
        <img
          className="w-full"
          src={`${image ? urlFor(image.asset._ref) : defaultImageURL}`}
          alt={name}
        />
        <div className="px-6 py-4">
          <h3 className="text-xl mb-2 text-center">{name}</h3>
        </div>
      </div>
    </>
  );
};

export default Actor;
Enter fullscreen mode Exit fullscreen mode

For actors, we only display their picture and name. Because we don't have pictures of all the actors in our backend, we specify an image url to be used if a picture is not available.

Next, in the Actors.tsx file, add the following:

// frontend/components/actor/Actors.tsx

import { React } from "../../dep.ts";
import { runQuery } from "../../SanityAPI.ts";
import Actor from "./Actor.tsx";

const { useState, useEffect } = React;
const Actors = () => {
  const [actor, setActors] = useState<any>([]);
  const updateActors = (actors: any[]) => {
    setActors(actors);
  };
  useEffect(() => {
    const query = `*[_type == 'person']{ _id, name, image}`;
    runQuery(query, updateActors);
  }, []);

  return (
    <div className="container mx-auto px-6">
      <h3 className="text-gray-700 text-2xl font-medium">A-List Movie Stars</h3>
      <div className="p-10 grid grid-cols-1 sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-1 xl:grid-cols-3 gap-5">
        {actor.map((actor: any) => (
          <Actor key={actor._id} {...actor} />
        ))}
      </div>
    </div>
  );
};

export default Actors;
Enter fullscreen mode Exit fullscreen mode

Here, we make a GROQ query to retrieve all the actors in our backed and map through the returned array, rendering and Actor component for each object in the array.

With these in place, we can update our App.tsx file as follows:

// frontend/components/App.tsx

import { React, Link, Route, Switch } from "../dep.ts";
import Movies from "./movie/Movies.tsx";
import Actors from "./actor/Actors.tsx";

const App = () => {
  return (
    <div className="bg-white">
      <header>
        <div className="container mx-auto px-6 py-3">
          <nav className="sm:flex sm:justify-center sm:items-center mt-4">
            <div className="flex flex-col sm:flex-row">
              <Link
                to="/movies"
                className="mt-3 text-gray-600 hover:underline sm:mx-3 sm:mt-0"
              >
                Movies
              </Link>
              <Link
                to="/actors"
                className="mt-3 text-gray-600 hover:underline sm:mx-3 sm:mt-0"
              >
                Actors
              </Link>
            </div>
          </nav>
        </div>
      </header>
      <main className="my-8">
        <Switch>
          <Route path="/" exact>
            <Movies />
          </Route>
          <Route path="/movies">
            <Movies />
          </Route>
          <Route path="/actors">
            <Actors />
          </Route>
        </Switch>
      </main>
    </div>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Here, we implement the client side routing for the React UI as well as style the container for the components.

Hydrating our application

At the moment, our application HTML is generated server-side. However, our application will not have the desired functionality because our JavaScript functionality isn't loaded on our page -clicking any link will trigger another render server-side instead of the routing being handled client-side. To fix this, we need to hydrate our React application as well as provide the JavaScript to be loaded client-side. This way, once the index page is loaded, the JavaScript will take over and handle everything else.

Let's start by creating the JavaScript to be used client-side. In the ssr directory, create a new file called client.tsx. In it, add the following:

// frontend/ssr/client.tsx

import { React, ReactDOM, BrowserRouter } from "../dep.ts";
import App from "../components/App.tsx";

ReactDOM.hydrate(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
  document.getElementById("root")
);
Enter fullscreen mode Exit fullscreen mode

Notice, we've also added a BrowserRouter to handle client-side routing once the page is loaded.

Next, we need to modify the ssr/server.tsx file to include the JavaScript bundle created (by Deno) from the ssr/client.tsx file. We also need to add a route that will allow the browser to download the bundled Javascript to be loaded client-side.

Update ssr/server.tsx to match the following:

// frontend/ssr/server.tsx

import { Application, Router } from 'https://deno.land/x/oak@v7.3.0/mod.ts';
import {
  React,
  ReactDOMServer,
  StaticRouter,
} from "../dep.ts";
import App from "../components/App.tsx";

const app = new Application();
const port: number = 8000;

const jsBundlePath = "/main.js";

const { diagnostics, files } = await Deno.emit("./ssr/client.tsx", {
  bundle: "esm",
  compilerOptions: { lib: ["dom", "dom.iterable", "esnext"] },
});

console.log(diagnostics);

const router = new Router();
router
  .get("/", (context) => {
    const app = ReactDOMServer.renderToString(
      <StaticRouter location={context.request.url} context={context}>
        <App />
      </StaticRouter>
    );
    context.response.type = "text/html";
    context.response.body = `
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="https://cdn.jsdelivr.net/npm/tailwindcss/dist/tailwind.min.css" rel="stylesheet">
    <title>Sanity <-> Deno</title>
    <script type="module" src="${jsBundlePath}"></script>
</head>
<body>
    <div id="root">${app}
    </div>
</body>
</html>`;
  })
  .get(jsBundlePath, (context) => {
    context.response.type = "application/javascript";
    context.response.body = files["deno:///bundle.js"];
  });

app.addEventListener("error", (event) => {
  console.error(event.error);
});

app.use(router.routes());
app.use(router.allowedMethods());

app.listen({ port });
console.log(`Server is running on port ${port}`);
Enter fullscreen mode Exit fullscreen mode

We specify the bundled Javascript path as /main.js and added it as a route to our oak application.

Next using the Deno.emit() compiler API, we transpile and bundle our client.tsx file into JavaScript that can be run on the browser. We also update the JSX for the index page to include a script tag pointing to our bundle path.

The Deno.emit() is currently marked as unstable which means we need to give our application explicit permission to perform this operation. We do this with the --unstable flag.

With this in place, our server side rendered app is ready for a test run. Start your Sanity project (if you stopped it).

cd backend

sanity start
Enter fullscreen mode Exit fullscreen mode

Then run your deno application using the command below

cd frontend

deno run --allow-net --unstable --allow-read ./ssr/server.tsx
Enter fullscreen mode Exit fullscreen mode

Navigate to http://localhost:8000/ to see your application in action.

Gif image of the movie app

Conclusion

Deno is relatively new in the world of web development but it is gradually gaining popularity and acceptance due to some of its advantages over Node.js. As seen in this tutorial, we learned about Deno and why it was built. We then proceeded to build a server-side rendered movie application using Deno and React.

For proper content management, we leverage the existing infrastructure put in place by Sanity to build the backend of the application. I hope this tutorial has given you some insight into what Deno is, and how to combine it with a frontend framework such as React.

The complete source code for the application built in this tutorial can be found here on GitHub. Happy coding!

Oldest comments (0)