Build and Deploy a gRPC-Web App Using Rust Tonic and React
Introduction
gRPC is a modern, high performance remote procedure call (RPC) framework that can be run in any environment. Built on protocol buffers (commonly called protobufs), gRPC is extensible and, efficient, and has wide support in many languages and runtimes. You can take a look at our what is gRPC post to learn more.
In this tutorial, we will go over how to deploy a React application backed by a Rust-based gRPC API to Koyeb. The demo application is a movie database website that feature showcases a selection of movies and their associated metadata. You can find the source for the two application components here:
Prerequisites
In order to follow along with this tutorial, you need the following:
- A Koyeb account to deploy the Rust and React services to. Koyeb's free tier allows you to run two services every month for free.
- The protobuf compiler installed on your local computer. We will use this to generate the language-specific stubs from our data format.
- Rust and
cargo
installed on your local computer to create the gRPC API service. - Node.js and
npm
installed on your local computer to create the React-based frontend.
Once you have satisfied the requirements, continue on to get started.
Create the Rust API
We will start by creating the Rust API for the backend and the protobuf file that defines the data format both of our services will use to communicate.
Create a new Rust project and install the dependencies
Start by defining a new Rust project.
Use the cargo
command to generate a new project directory initialized with the expected package files:
cargo new movies-back
Next, move into the new project directory and install the project's dependencies:
cd movies-back
cargo add tonic@0.9.2 tonic-web@0.9.2
cargo add prost@0.11 prost-types@0.11
cargo add --build tonic-build@0.8
cargo add tonic-health@0.9.2
cargo add tower-http@0.2.3
cargo add --features tokio@1.0/macros,tokio@1.0/rt-multi-thread tokio@1.0
By default, web browsers do not support gRPC, but we will use gRPC-web to make it possible.
Define the data format
Next, create the data format by creating a protobuf definition file.
Create a new directory called proto
:
mkdir proto
Inside, create a new file named proto/movie.proto
with the following contents:
syntax = "proto3";
package movie;
message MovieItem {
int32 id = 1;
string title = 2;
int32 year = 3;
string genre = 4;
string rating = 5;
string starRating = 6;
string runtime = 7;
string cast = 8;
string image = 9;
}
message MovieRequest {
}
message MovieResponse {
repeated MovieItem movies = 1;
}
service Movie {
rpc GetMovies (MovieRequest) returns (MovieResponse);
}
The proto/movie.proto
file defines our data format using the protobuf format. It specifies a data structure to hold all of the data about a movie and outlines what a request and response for that data will look like. This definition will be used to define the API between our services.
Create the backend service
Now that we have our data format, we can create our backend service.
Start by configuring the Rust build process to compile the protobuf file. Create a file called build.rs
with the following content:
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::compile_protos("./proto/movie.proto")?;
Ok(())
}
Now we can implement the actual API server with the GetMovies
endpoint. Open the src/main.rs
file and replace the contents with the following:
use std::env;
use tonic::{transport::Server, Request, Response, Status};
pub mod grpc_movie {
tonic::include_proto!("movie");
}
use grpc_movie::movie_server::{Movie, MovieServer};
use grpc_movie::{MovieRequest, MovieResponse};
#[derive(Debug, Default)]
pub struct MovieService {}
#[tonic::async_trait]
impl Movie for MovieService {
async fn get_movies(
&self,
request: Request<MovieRequest>,
) -> Result<Response<MovieResponse>, Status> {
println!("Got a request: {:?}", request);
let mut movies = Vec::new();
movies.push(grpc_movie::MovieItem {
id: 1,
title: "Matrix".to_string(),
year: 1999,
genre: "Sci-Fi".to_string(),
rating: "8.7".to_string(),
star_rating: "4.8".to_string(),
runtime: "136".to_string(),
cast: "Keanu Reeves, Laurence Fishburne".to_string(),
image: "http://image.tmdb.org/t/p/w500//aOIuZAjPaRIE6CMzbazvcHuHXDc.jpg".to_string(),
});
movies.push(grpc_movie::MovieItem {
id: 2,
title: "Spider-Man: Across the Spider-Verse".to_string(),
year: 2023,
genre: "Animation".to_string(),
rating: "9.7".to_string(),
star_rating: "4.9".to_string(),
runtime: "136".to_string(),
cast: "Donald Glover".to_string(),
image: "http://image.tmdb.org/t/p/w500//8Vt6mWEReuy4Of61Lnj5Xj704m8.jpg".to_string(),
});
movies.push(grpc_movie::MovieItem {
id: 3,
title: "Her".to_string(),
year: 2013,
genre: "Drama".to_string(),
rating: "8.7".to_string(),
star_rating: "4.1".to_string(),
runtime: "136".to_string(),
cast: "Joaquin Phoenix".to_string(),
image: "http://image.tmdb.org/t/p/w500//eCOtqtfvn7mxGl6nfmq4b1exJRc.jpg".to_string(),
});
let reply = grpc_movie::MovieResponse { movies: movies };
Ok(Response::new(reply))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let port = env::var("PORT").unwrap_or("50051".to_string());
let addr = format!("0.0.0.0:{}", port).parse()?;
let movie = MovieService::default();
let movie = MovieServer::new(movie);
let movie = tonic_web::enable(movie);
let (mut health_reporter, health_service) = tonic_health::server::health_reporter();
health_reporter
.set_serving::<MovieServer<MovieService>>()
.await;
println!("Running on port {}...", port);
Server::builder()
.accept_http1(true)
.add_service(health_service)
.add_service(movie)
.serve(addr)
.await?;
Ok(())
}
The application uses the tonic gRPC implementation to build and serve the API for our backend based on the structures and interfaces defined by the protobuf file. In a real world scenario, this would typically be backed by a database containing the movie data, but to simplify the implementation, we just include data for a few movies inline.
The service will run on port 50051 by default (modifiable with the PORT
environment variable) and will respond for requests for movies using with response objects as defined by the protobuf file.
Test the API backend
The API backend is now complete, so we can start the server locally to test its functionality by typing:
cargo run
The API server will be built and start running on port 50051. You can test the functionality using a gRPC client of your choice like grpcurl
or Postman.
For example, you can request the list of movies using grpcurl
by typing the following in your project's root directory with the Rust service running:
grpcurl -plaintext -import-path proto -proto movie.proto 127.0.0.1:50051 movie.Movie/GetMovies
The service should return the list of movies as expected:
{
"movies": [
{
"id": 1,
"title": "Matrix",
"year": 1999,
"genre": "Sci-Fi",
"rating": "8.7",
"starRating": "4.8",
"runtime": "136",
"cast": "Keanu Reeves, Laurence Fishburne",
"image": "http://image.tmdb.org/t/p/w500//aOIuZAjPaRIE6CMzbazvcHuHXDc.jpg"
},
{
"id": 2,
"title": "Spider-Man: Across the Spider-Verse",
"year": 2023,
"genre": "Animation",
"rating": "9.7",
"starRating": "4.9",
"runtime": "136",
"cast": "Donald Glover",
"image": "http://image.tmdb.org/t/p/w500//8Vt6mWEReuy4Of61Lnj5Xj704m8.jpg"
},
{
"id": 3,
"title": "Her",
"year": 2013,
"genre": "Drama",
"rating": "8.7",
"starRating": "4.1",
"runtime": "136",
"cast": "Joaquin Phoenix",
"image": "http://image.tmdb.org/t/p/w500//eCOtqtfvn7mxGl6nfmq4b1exJRc.jpg"
}
]
}
Create a Dockerfile
When we deploy the backend to Koyeb, the application will be run in a container. We can define a Dockerfile
for our project to describe how the project's code should be packaged and run.
In the project's root directory, create a new file called Dockerfile
with the following content:
FROM rust:1.64.0-buster as builder
# install protobuf
RUN apt-get update && apt-get install -y protobuf-compiler libprotobuf-dev
COPY Cargo.toml build.rs /usr/src/app/
COPY src /usr/src/app/src/
COPY proto /usr/src/app/proto/
WORKDIR /usr/src/app
RUN rustup target add x86_64-unknown-linux-musl
RUN cargo build --target x86_64-unknown-linux-musl --release --bin movies-back
FROM gcr.io/distroless/static-debian11 as runner
# get binary
COPY --from=builder /usr/src/app/target/x86_64-unknown-linux-musl/release/movies-back /
# set run env
EXPOSE 50051
# run it
CMD ["/movies-back"]
When you are finished, add all of the files for the API to a new GitHub repository so that we can deploy it to production later.
We are ready now to create the react application that will consume the API.
Create the React application
Now that the backend is complete, we can begin working on the React frontend service for our application.
Generate a new React project
The fastest way to create a basic react application is with the create-react-app
. Check to make sure you are not in the Rust service's directory and then type:
npx create-react-app movies-front
This will a new project directory for your frontend and generate some basic files to help you get started.
Move into the new directory and start the service to validate that everything installed correctly:
cd movies-front
npm run start
A development server will open on port 3000 and React will attempt to open a new browser window to view it. You can visit localhost:3000
if you are not automatically directed there. The default React development page should appear:
Press Ctrl-c when you are finished to stop the development server.
Configure the Tailwind CSS framework
Our react application will show a list of movies on a page. To speed up the process of styling it, we are going to use Tailwind CSS. Install the necessary packages and initialize Tailwind by typing:
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Now that Tailwind is installed, we need to configure the React application to support it.
First, open the tailwind.config.js
file. Modify the content
property to pick up all of our expected CSS content:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {},
},
plugins: [],
}
We will be using Sass instead of CSS directly, so we need to install the sass
package next:
npm install sass
Remove the src/App.css
file and replace it with an src/App.scss
. Inside, we import all of the Tailwind code that we require:
@tailwind base;
@tailwind components;
@tailwind utilities;
We can test Tailwind by changing the main React application file. Change the contents of the src/App.js
file like this:
import './App.scss'
import Movie from './Movie'
function App() {
// for now we will
let movies = [
{
id: 1,
title: 'The spiderman across the spider verse',
year: 2023,
genre: 'animation',
rating: 'L',
starRating: '4.9',
runtime: '2h 22min',
cast: 'Donald Glover',
image: 'http://image.tmdb.org/t/p/w500//8Vt6mWEReuy4Of61Lnj5Xj704m8.jpg',
},
]
return (
<div className="App">
{movies.map((movie) => (
<Movie key={movie.id} details={movie} />
))}
</div>
)
}
export default App
Here, we create an application that will serve our movie list when the page is requested. At this point, we just mock up a single movie so that we can test our CSS styling.
Create a src/Movie.js
file to define how the movie should be styled and displayed:
export default function Movie(props) {
let movie = props.details
return (
<article className="flex items-start space-x-6 p-6">
<img src={movie.image} alt="" width="60" height="88" className="flex-none rounded-md bg-slate-100" />
<div className="relative min-w-0 flex-auto">
<h2 className="truncate pr-20 font-semibold text-slate-900">{movie.title}</h2>
<dl className="mt-2 flex flex-wrap text-sm font-medium leading-6">
<div className="absolute right-0 top-0 flex items-center space-x-1">
<dt className="text-sky-500">
<span className="sr-only">Star rating</span>
<svg width="16" height="20" fill="currentColor">
<path d="M7.05 3.691c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.372 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.539 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.783.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.363-1.118L.98 9.483c-.784-.57-.381-1.81.587-1.81H5.03a1 1 0 00.95-.69L7.05 3.69z" />
</svg>
</dt>
<dd>{movie.starRating}</dd>
</div>
<div>
<dt className="sr-only">Rating</dt>
<dd className="rounded px-1.5 ring-1 ring-slate-200">{movie.rating}</dd>
</div>
<div className="ml-2">
<dt className="sr-only">Year</dt>
<dd>{movie.year}</dd>
</div>
<div>
<dt className="sr-only">Genre</dt>
<dd className="flex items-center">
<svg
width="2"
height="2"
fill="currentColor"
className="mx-2 text-slate-300"
aria-hidden="true"
>
<circle cx="1" cy="1" r="1" />
</svg>
{movie.genre}
</dd>
</div>
<div>
<dt className="sr-only">Runtime</dt>
<dd className="flex items-center">
<svg
width="2"
height="2"
fill="currentColor"
className="mx-2 text-slate-300"
aria-hidden="true"
>
<circle cx="1" cy="1" r="1" />
</svg>
{movie.runtime}
</dd>
</div>
<div className="mt-2 w-full flex-none font-normal">
<dt className="sr-only">Cast</dt>
<dd className="text-slate-400">{movie.cast}</dd>
</div>
</dl>
</div>
</article>
)
}
If you start up the development server again, you should be able to see the movie mocked out:
npm run start
Fetch movie lists from the API
Next, instead of displaying a hardcoded movie, we will modify the application to fetch data from the gRPC API.
First, install protobuf and gRPC web support for React:
npm install google-protobuf@~3.21.2 grpc-web@~1.4.1
Next, we need to generate the movie entity in JavaScript based on the same protobuf file we defined for the backend Rust application. To do this, we will use the protoc
command included in the protobuf installation from the prerequisites.
Assuming the movies-back
and movies-front
are located next to each other, you can generate the appropriate JavaScript files with the protobuf definition by running the following command in the movies-front
directory:
# change the proto_path to the correct place where movie.proto is.
protoc --proto_path=../movies-back/proto/ movie.proto --grpc-web_out=import_style=commonjs,mode=grpcweb:src --js_out=import_style=commonjs,binary:src
This will generate the appropriate JavaScript stubs from your protobuf file so that the React application understands how to communicate with the API.
Now we can change the src/App.js
file to use gRPC instead of serving a hardcoded movie:
import { useEffect, useState } from 'react'
import './App.scss'
import Movie from './Movie'
const proto = {}
proto.movie = require('./movie_grpc_web_pb.js')
function App() {
let url = process.env.REACT_APP_BACKEND_URL
if (url == null) {
url = 'http://localhost:50051'
}
const client = new proto.movie.MovieClient(url, null, null)
let [movies, setMovies] = useState([])
useEffect(() => {
const req = new proto.movie.MovieRequest()
client.getMovies(req, {}, (err, response) => {
if (response == null) {
return
}
if (response.getMoviesList().length === 0) {
return
}
let m = []
response.getMoviesList().forEach((movie) => {
console.log(movie.toObject())
m.push(movie.toObject())
})
setMovies(m)
})
}, [])
return (
<div className="App">
{movies.map((movie) => (
<Movie key={movie.id} details={movie} />
))}
</div>
)
}
export default App
Start the development server again to test the new changes:
npm run start
This time, you should see the full movie list as served from the API backend:
After confirming that the application works as expected, add the React app's files to a new GitHub repository and push them. We will deploy the application to production in the next stage.
Deploy the application to Koyeb
Now that our frontend and backend are working as expected, we can deploy both services to Koyeb.
Deploy the API service
First, we will deploy the Rust application to Koyeb.
On the Overview tab of the Koyeb control panel, click the Create Web Service button to get started:
- Select GitHub as your deployment method.
- Select the repository for the backend API from your repository list. Alternatively, you can use the example repo for this service which contains the same code we've discussed by putting
https://github.com/filhodanuvem/movies-rust-grpc
in the Public GitHub repository field. - In the Builder section, choose Dockerfile.
- In the Exposed ports section, change the port value to 50051 and select HTTP/2 from the protocol list. Set the path to
/api
. - Click Deploy.
On the API's service page, copy the value of the Public URL. We will need this value when configuring our React application.
Deploy the React application
Now that the API is running we can deploy the React application. Unlike the API, we will use the buildpack builder for this project rather than building from a Dockerfile.
In the Apps tab of the Koyeb control panel, click on the application that includes the API service you just deployed. From the service's index page, click the Create Service button to deploy an additional service within the context of the same Koyeb App:
- Select GitHub as the deployment method.
- Select the repository for the frontend React application from your repository list. Alternatively, you can use the example repo for this service which contains the same code we've discussed by putting
https://github.com/filhodanuvem/movies-react
in the Public GitHub repository field. - In the Environment variables section, click the Add variable button and create a new variable called
REACT_APP_BACKEND_URL
. Use the API service's public URL that you copied as the value. It should look something like the following:REACT_APP_BACKEND_URL=https://<YOUR_APP_NAME>-<KOYEB_ORG_NAME>.koyeb.app/api
. - Click Deploy.
Conclusion
In this guide, we created and deployed an end-to-end application composed of a Rust backend and a React frontend. The two services communicate using gRPC. We deployed both services to Koyeb to take advantage of its native gRPC support. The services are able to communicate securely in order to fulfill user requests for the movie database site.
If you have any questions or suggestions, feel free to reach out on our community Slack.