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."
- Ryan Dahl, creator of Node.js at 2018 JSConf EU.
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 thenode_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.
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:
- Deno
- Node Package Manager (NPM)
- Sanity CLI
- 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
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
In the frontend
directory, create two new directories, ssr
and components
.
mkdir frontend/ssr frontend/components
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
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}`);
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
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:
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
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:
- React, ReactDOM and ReactDOMServer for our React UI.
- React Router to handle routing between components
- 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 };
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;
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}`);
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.
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
You will be prompted to provide some information. Proceed as follows:
- Select the
Create new project
option - Name the project
react_deno-backend
- Use the default dataset configuration (press
Y
) - Select the project output path (by default it would be the
backend
directory) - Select the movie project (schema + sample data) option. Using the arrow keys, navigate to that option and press enter when it turns blue.
- 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
Your studio will be similar to the screenshot below.
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
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);
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
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;
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;
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;
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;
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;
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")
);
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}`);
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
Then run your deno application using the command below
cd frontend
deno run --allow-net --unstable --allow-read ./ssr/server.tsx
Navigate to http://localhost:8000/ to see your application in action.
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!