How to Create an End-to-End Serverless App with React, Apollo, Lambda and AWS RDS

Author Date May 18, 2020 Read 15 min
REST has been dominating the API design architecture for over a decade. In recent years, developers are looking for an alternative that provides faster development, greater flexibility and…

REST has been dominating the API design architecture for over a decade. In recent years, developers are looking for an alternative that provides faster development, greater flexibility and simplified data management.

GraphQL, created by Facebook in 2012, has become one of the most popular REST alternatives. From the 2019 state of JS survey, 30.7% of the developers have used GraphQL and would use it again. Compared with other data layer technologies, it is ranked as having the greatest awareness, interest, and satisfaction. It allows clients to define their desired response data structure, reducing overall fetch time and development cost.

Apollo is a platform that implements GraphQL and provides both server-side and client-side libraries for your app.  Serverless is a framework that simplifies the deployment of serverless technologies, such as AWS Lambda.  For this how-to guide, we will be building a GraphQL API using Apollo, deploying it to an AWS Lambda using the Serverless Framework.

Building the Application

The sample application we will be building is a web app that will present a user with details and workout videos based on which muscle the user selects in an anatomy diagram. The app has a React client that communicates to the backend through GraphQL (Apollo) through an AWS API Gateway and Lambda.  Data will be stored in a MySQL RDS database.

We will create the sample app step by step. The app source code could be found here and final results will look like this.

AWS account and database set up

This application requires an AWS account.  Regardless of whether you need to create a new account or are reusing an existing account, this application stays within AWS’ free tier. If you need to create an account, you can follow this link.

After your registration, we will use the AWS web console to create a MySQL RDS database for the app.  This RDS instance will serve as the persistence layer for our Apollo server.  Also note that Apollo server can use other data storage means in AWS, such as DynamoDB.

To create your database, you can choose the easy create method to make sure you are still within the free tier range.

After the database is created, you will be able to access it through the AWS CLI or a database client tool. Since it’s a mysql database, I am using Mysql workbench to connect. The user and password are the ones that you generated at creation and the host and port can be found under Connectivity & Security tab.

Before you connect for the first time, you will need to edit the inbound rules on EC2→Security Groups to make sure that it is open to your local IP address. I am setting the source to 0.0.0.0/0 for simplicity, allowing access from any IP address. While this is OK for prototyping, this is NOT suitable for production applications.

Now you are able to connect the database locally. Feel free to set up the database following the diagram or just simply import from the sql file.


Creating the Apollo Server

Make sure that you have node and npm installed. In order to automatically restart your server application while making code changes, I also recommend installing nodemon.

1. Create a new node project

$ npm init

2. Install Apollo server dependencies

$ npm install --save apollo-server graphql

3. Add a new index file ./src/index.js and create the ApolloServer instance.

const { ApolloServer } = require('apollo-server');

const server = new ApolloServer({});

// The `listen` method launches a web server.
server.listen().then(({ url }) => {
  console.log(`Muscle server ready at ${url}`);
});

4. Start the server

Run $ nodemon src/index.js to start the Apollo server.  The default port is 4000.

5. Add schemas

Schema describes the shape of your data graph. It defines the type system, and which queries and mutations can be executed against the backend.

Let’s create a schema file ./src/schema.js. We will define object types for our muscles and videos. Object types represent a kind of object you can fetch from a data source and contains a collection of fields. Each field is usually a built-in scala type. For example, the id ID below is a scala type and it’s a unique identifier for the object, often used to refetch an object or as the key of a cache. The ID is serialized as a String type but is not intended to be human readable. ! means that the field is required and cannot be null. The field can also be a defined type like videos: [Video] below, which declares videos to be an array of Videos.

const { gql } = require('apollo-server');
const typeDefs = gql`
  type Muscle {
    id: ID!
    name: String
    description: String
    videos: [Video]
  }
  type Video {
    id: ID!
    name: String
    description: String
    videoLink: String
    imageLink: String
  }
`;

module.exports = typeDefs;

A schema also specifies which queries and mutations are available for clients to execute against your data graph. A Query type contains a collection of fields that a user can fetch against the data graph. A Mutation type is similar to Query type, but it enables the user to modify the data.

Every GraphQL Service has a Query type, but a Mutation type is optional. For our sample app, we will only add Query type.

Below, we define four queries for clients to execute: Muscles, Muscle, Videos and Video. For example, muscles query will return an array of Muscles. muscle query returns a single Muscle based on id argument. The id can not be null and needs to be the scalar ID type.

const { gql } = require('apollo-server');
const typeDefs = gql`
  type Muscle {
    id: ID!
    name: String
    description: String
    videos: [Video]
  }
  type Video {
    id: ID!
    name: String
    description: String
    videoLink: String
    imageLink: String
  }
  type Query {
    muscles: [Muscle]!
    muscle(id: ID!): Muscle
    videos: [Video]!
    video(id: ID!): Video 
  }
`;
module.exports = typeDefs;

Don’t forget to import the schemas to our index.js.

const { ApolloServer } = require('apollo-server');
const typeDefs = require('./schema');
const muscleDatabase = require("./datasources/muscle");

const server = new ApolloServer({
  typeDefs
});

// The `listen` method launches a web server.
server.listen().then(({ url }) => {
  console.log(`Muscle server ready at ${url}`);
});

6. Add data source

Apollo server can fetch data from restful APIs, micro services or directly from databases. In this example, we will use the MySQL RDS database created above. I’m using a library named datasource-sql, which is contributed by the Apollo community. It simplifies querying a database. The library combines the power of Knex as the SQL query builder. Knex not only provides common functions like select, join, and where, but also supports options like nestTables to get a query result in a nested object format if set to true.

Install the dependency

$ npm install --save datasource-sql

Create a datasource file ./src/datasources/muscle.js and add the methods to fetch the muscles and videos.

const { SQLDataSource } = require('datasource-sql');
class MuscleDatabase extends SQLDataSource {
  getMuscles() {
    return this.knex
      .select('*')
      .from('muscles');
  }
  getMuscleById(id) {
    return this.knex
      .select('*')
      .from('muscles AS muscle')
      .leftJoin('muscleVideos AS mv', 'mv.muscleId', 'muscle.id')
      .leftJoin('videos AS video', 'mv.videoId', 'video.id')
      .where('muscle.id', '=', id)
      .options({nestTables: true})
      .then(results => {
        return this.muscleReducer(results);
      });
  }
  getVideoById(id) {
    return this.knex
      .select('*')
      .from('videos AS video')
      .leftJoin('muscleVideos AS mv', 'mv.videoId', 'video.id' )
      .leftJoin('muscles AS muscle', 'mv.muscleId', 'muscle.id')
      .where('video.id', '=', id)
      .options({nestTables: true})
      .then(results => {
        return this.videoReducer(results);
      });
  }
  
  muscleReducer(muscles) {
    if(muscles.length > 0) {
      const videos = muscles.map(muscle => muscle.video)
      return {
        ...muscles[0].muscle,
        videos
      };
    }
    return null;
  }
  
  videoReducer(videos) {
    if(videos.length > 0) {
      const muscles = videos.map(video => video.muscle)
      return {
        ...videos[0].video,
        muscles
      };
    }
    return null;
  }
}
module.exports = MuscleDatabase;

We also need to add the database connections and datasource to our index.js.

const { ApolloServer } = require('apollo-server');
const typeDefs = require('./schema');
const muscleDatabase = require("./datasources/muscle");
const knexConfig = {
  client: 'mysql',
  connection: {
    host : 'your db host name',
    user : 'your db user',
    password : 'your db password',
    database : 'muscle'
  }
};
const server = new ApolloServer({
  typeDefs,
  dataSources: () => ({ 
    muscleDatabase: new muscleDatabase(knexConfig)
  })
 });

// The `listen` method launches a web server.
server.listen().then(({ url }) => {
  console.log(`Muscle server ready at ${url}`);
});

7. Add Resolvers

The last step we need to add to make the server functional are resolvers which are responsible for populating the data for a single field in your schema.

Create a resolver file  ./src/resolver.js.

module.exports = {
  Query: {
    muscles:(_, {}, { dataSources }) => 
      dataSources.muscleDatabase.getMuscles(),
    muscle: (_, { id }, { dataSources }) =>
      dataSources.muscleDatabase.getMuscleById(id),
    video: (_, { id }, { dataSources }) =>
      dataSources.muscleDatabase.getVideoById(id),
  }
};

Don’t forget to import it in index.js.

const { ApolloServer } = require('apollo-server');
const typeDefs = require('./schema');
const muscleDatabase = require("./datasources/muscle");
const resolvers = require('./resolvers');
const knexConfig = {
  client: 'mysql',
  connection: {
    host : 'your db host name',
    user : 'your db user',
    password : 'your db password',
    database : 'muscle'
  }
};
const server = new ApolloServer({
  typeDefs,
  resolvers,
  dataSources: () => ({ 
    muscleDatabase: new muscleDatabase(knexConfig)
  })
 });

// The `listen` method launches a web server.
server.listen().then(({ url }) => {
  console.log(`Muscle server ready at ${url}`);
});

At this point, we should be able to query video and muscle by id on  http://localhost:4000. Open up localhost on your browser and you will be able to query the data on the GraphQL Playground.

Deploy Apollo Server with AWS Lambda

1. Install AWS CLI if you haven’t

2. Configure the AWS CLI

3. Install the serverless globally

$ npm install -g serverless

4. Add serverless.yml at the root of the project to config the serverless

# serverless.yml
service: apollo-lambda
provider:
  name: aws
  runtime: nodejs12.x
functions:
  graphql:
    # this is formatted as <FILENAME>.<HANDLER>
    handler: src/graphql.graphqlHandler
    events:
    - http:
        path: graphql
        method: post
        cors: true
    - http:
        path: graphql
        method: get
        cors: true

5. Install apollo-server-lambda to your project

npm install --save apollo-server-lambda

6. Add ./src/graphql.js to the project.

It has to be the exact name graphql.js for severless to pick it up. This file is really similar to the index.js, but it exports a graphqlHander with a lambda function handler and also creates an endpoint for the playground.

const { ApolloServer } = require('apollo-server-lambda');
const typeDefs = require('./schema');
const muscleDatabase = require("./datasources/muscle");
const resolvers = require('./resolvers');
const knexConfig = {
  client: 'mysql',
  connection: {
    host : 'your db host name',
    user : 'your db user',
    password : 'your db password',
    database : 'muscle'
  }
};
const server = new ApolloServer({
  typeDefs,
  resolvers,
  dataSources: () => ({ 
    muscleDatabase: new muscleDatabase(knexConfig)
  }),
  playground: {
    endpoint: "/dev/graphql"
  }
});
exports.graphqlHandler = server.createHandler({
  cors: {
    origin: '*',
    credentials: false,
  },
});

7. Deploy the Apollo server by running serverless

$ sls deploy

You should be getting the output from the console that giving you the endpoints for query

Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service apollo-lambda.zip file to S3 (9.25 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
..............
Serverless: Stack update finished...
Service Information
service: apollo-lambda
stage: dev
region: us-east-1
stack: apollo-lambda-dev
resources: 13
api keys:
  None
endpoints:
  POST - url for post
  GET - url for get
functions:
  graphql: apollo-lambda-dev-graphql
layers:
  None
Serverless: Removing old service artifacts from S3...
Serverless: Run the "serverless" command to setup monitoring, troubleshooting and testing.

8. Test

You should be able to see the function in AWS lambda

If you click on the function name, you can see the API gateway associated with it. And the endpoint should be the same url from your serverless console.

You can also find the different versions of the lambda function you have deployed in S3.

You are able to access the GraphQL Playground by the Api endpoint + /dev/graphql. You can also test it through Postman.

Install the latest Postman. (It started supporting GraphQl from v7.2)


Creating the React Apollo Client

For the client project, I’m using the React framework. Apollo conveniently provides an Apollo client library for React and also for other popular SPA frameworks like Angular and Vue.js.

The application is very simple; it contains two pages: home(muscle) page and video detail page.

Home page allows users to click on a single muscle on the body svg and show the corresponding muscle details and video list. Each video on the list directs users to a video detail page.

1. Create react app

npx create-react-app client --template typescript

2. Install Apollo packages

npm install --save apollo-boost @apollo/react-hooks graphql

3. Add environment files to the root of the folder to define Api endpoints. At /client/.env, add

REACT_APP_API = “http://localhost:4000/grapql” // localhost api endpoint

4. Create Apollo Client at /client/src/index.tsx

import React from 'react';
import ReactDOM from 'react-dom';
import ApolloClient from 'apollo-boost';
import { ApolloProvider } from '@apollo/react-hooks';

const client = new ApolloClient({
  uri: process.env.REACT_APP_API,
});

const App = () => (
  <ApolloProvider client={client}>
    <div><h2>My Muscle App</h2></div>
  </ApolloProvider>
);

ReactDOM.render(<App />, document.getElementById('root'));

Run npm run start and you should be able to see a basic app running on http://localhost:3000

5. Add Home Page

a. Define the muscle and video type

      ./client/src/types/muscle.tsx
import {Video} from './video';
export interface Muscle {
  id?: string;
  name?: string;
  description?: string;
  videos?: [ Video ];
}
./client/src/types/video.tsx
export interface Video {
  id?: string;
  name?: string;
  description?: string;
  imageLink?: string;
  videoLink?: string;
}

b. Add Muscle Detail Component

Let’s create the components that the muscle page needs.

Add ./client/src/components/muscle-detail.tsx

Apollo clients provide useQuery hook from @apollo/react-hooks which is an api that executes queries and shares GraphQL data with the UI. We will pass a gql getting muscle detail query string to the userQuery and it returns an object that contains loading, error and data if successful. Apollo Client kindly maintains the state for you.

import React, { Fragment } from 'react';
import { useQuery } from '@apollo/react-hooks';
import gql from 'graphql-tag';
import { Muscle } from '../types/muscle';
import { VideoMeta } from './index';
export const GET_MUSCLE_DETAIL = gql`
  query MuscleDetail($muscleId: ID!) {
    muscle(id: $muscleId) {
      name
      description 
      videos {
        id
        name
        imageLink
        videoLink
      }
    }
  }
`;
interface MuscleId {
  muscleId?: number
}
interface MuscleRespond {
  muscle: Muscle
}
const MuscleDetail: React.FC<MuscleId> = ({muscleId}) => {
  const { 
    data, 
    loading, 
    error 
  } = useQuery<
    MuscleRespond , MuscleId
    >(GET_MUSCLE_DETAIL,
    { variables: { muscleId } }
  );
  
  let videoDom;
  if (loading) return <p>Loading...</p>;
  if (error) return <p>ERROR: {error.message}</p>;
  if (!data || !data.muscle) return <p>Not found</p>;
  if (data.muscle && data.muscle.videos) {
    videoDom = 
    <div className="videos-list">
      <p><b>Videos</b></p>
      {data.muscle.videos.map((value) => {
        return <VideoMeta {...value}></VideoMeta>
      })}
    </div>
  }
  return (
    <Fragment>
      <h2>{ data.muscle.name }</h2>
      <p>{ data.muscle.description}</p>
      {videoDom}
    </Fragment>
  )
}
export default MuscleDetail;

We can see there’s a VideoMeta component missing which renders a list of videos related to the specific muscle.

Add ./client/src/components/video-meta.tsx. Each video-meta block has a link to the corresponding video detail page. I’m using Material UI for grid and react-router-dom for routing so install them first:

npm install --save @material-ui/core react-router-dom
import React from 'react';
import { Video } from '../types/video';
import Grid from '@material-ui/core/Grid';
import { Link } from 'react-router-dom';
const VideoMeta: React.FC<Video> = (video) => {
  return (
    <Grid container spacing={1} className="video-meta">
      <Grid item sm={4} xs={12} md={4}>
        <Link to={'/video/'+video.id}><img src={video.imageLink} alt={video.name}></img></Link>
      </Grid>
      <Grid item sm={8} xs={12} md={8}>
        <h3><Link to={'/video/'+video.id}>{video.name}</Link></h3>
        <p>{video.description}</p>
      </Grid>
    </Grid>
  )
}
export default VideoMeta;

c. Create Body Image Component

This component contains an svg of the whole body and allows users to click on each muscle piece. The page is large, but can be referenced here.

d. Add an index file to export all the components.

./client/src/components/index.tsx
export { default as MuscleDetail } from './muscle-detail';
export { default as VideoMeta } from './video-meta';
export { default as BodyImage } from './body-image';

e. Create Home Page Component

Add ./client/src/pages/muscle.tsx. The muscle id is emitted from the body-image component and the corresponding muscle data will be fetched by the muscle-detail component.

import React, { Component } from 'react';
import { MuscleDetail, BodyImage } from '../components'; 
import Grid from '@material-ui/core/Grid';
interface MusclePageProps {}
interface MusclePageState {
  muscleId?: number;
}
class MusclePage extends Component<MusclePageProps, MusclePageState> {
  constructor(props: MusclePageProps) {
    super(props);
    this.state = {}
    this.onMuscleClick = this.onMuscleClick.bind(this);
  }
  component = this;
 
  onMuscleClick(muscleId: number) {
    this.setState( state => (
      {
        muscleId: muscleId
      }
    ));
  }
  render() {
    return (
      <Grid container spacing={2} className="muscle-page-container">
        <Grid item xs={12} sm={12} md={6}> 
          <BodyImage {...{muscleId: this.state.muscleId, onMuscleClick: this.onMuscleClick}}></BodyImage>
        </Grid>
        <Grid item xs={12} sm={12} md={6}>
          {this.state.muscleId && <MuscleDetail  {... {muscleId: this.state.muscleId}} />}
          {!this.state.muscleId && <h2>Please select a muscle to start</h2> }
        </Grid>
      </Grid>
    )
  }
}
export default MusclePage

6. Add Video Page

a. Add ./client/src/components/video-detail.tsx. It is similar to the muscle-detail component, except we are querying video by its id this time.

import React, { Fragment } from 'react';
import { useQuery } from '@apollo/react-hooks';
import gql from 'graphql-tag';
import { Video } from '../types/video';
export const GET_VIDEO_DETAIL = gql`
  query VideoDetail($videoId: ID!) {
    video(id: $videoId) {
      id
      name
      description
      videoLink
    }
  }
`;
interface VideoId {
  videoId?: number
}
interface VideoRespond {
  video: Video
}
const VideoDetail: React.FC<VideoId> = ({videoId}) => {
  const { 
    data, 
    loading, 
    error 
  } = useQuery<
    VideoRespond , VideoId
    >(GET_VIDEO_DETAIL,
    { variables: { videoId } }
  );
  
  if(!videoId) return <h2>Please select a video to start</h2>;
  if (loading) return <p>Loading...</p>;
  if (error) return <p>ERROR: {error.message}</p>;
  if (!data || !data.video) return <p>Not found</p>;
  return (
    <Fragment>
      <h1>{ data.video.name }</h1>
      <p>{ data.video.description }</p>
      <iframe title={data.video.name} width="420" height="315"
        src={ data.video.videoLink }>
      </iframe>
    </Fragment>
  )
}
export default VideoDetail;

b. Add the component to the ./client/src/components/index.tsx

export { default as MuscleDetail } from './muscle-detail';
export { default as BodyImage } from './body-image';
export { default as VideoMeta } from './video-meta';
export { default as VideoDetail } from './video-detail';

c. Add ./client/src/pages/video.tsx

import React, { Component } from 'react';
import { VideoDetail } from '../components'; 
import Grid from '@material-ui/core/Grid';
import { Link } from 'react-router-dom';
interface VideoPageProps {
  match? :any
}
interface VideoPageState {}
class VideoPage extends Component<VideoPageProps, VideoPageState> {
  render() {
    const videoId = this.props.match.params.id;
    return (
      <Grid container className="video-page-container">
        <Grid item xs={12} sm={12} md={12}> 
          <Link to="/">Home</Link>
          <VideoDetail {...{videoId: videoId }} />
        </Grid>
      </Grid>
    )
  }
}
export default VideoPage

7. Add Routing to the app

Add a file for routing at /client/src/routing.tsx

The video detail page takes the video id as a url parameter.

import React from "react";
import {
  BrowserRouter as Router,
  Switch,
  Route,
} from "react-router-dom";
import MusclePage from './pages/muscle';
import VideoPage from './pages/video';
export default function Routing() {
  return (
    <Router>
      <Switch>
        <Route exact path="/">
          <MusclePage />
        </Route>
        <Route path="/video/:id" component={VideoPage} />
      </Switch>
    </Router>
  );
}

Import the routing into ./client/src/index.tsx

import React from 'react';
import ReactDOM from 'react-dom';
import ApolloClient from 'apollo-boost';
import { ApolloProvider } from '@apollo/react-hooks';
import Routing from './routing'

const client = new ApolloClient({
  uri: process.env.REACT_APP_API,
});

const App = () => (
  <ApolloProvider client={client}>
    <Routing />
  </ApolloProvider>
);

ReactDOM.render(<App />, document.getElementById('root'));

8. Test

Now the whole app should be running on http://localhost:3000 !

Apollo provides Client Developer Tools, allowing you to inspect an element and see all the queries, mutations and cache.


Deploy Apollo Client

I am hosting the app simply on the github pages.

To deploy on github you will need to push the project to a repo first.

1. Configure github pages at the Setting tab of the repository.

2. Install gh-pages

npm install gh-pages --save-dev

3. Add following scripts to the package.json

  "scripts": {

    "predeploy": "npm run build",

    "deploy": "gh-pages -d build"

...

}

4. Add Homepage property to the package.json and assign it to your github page url

  "homepage": "https://daituzhang.github.io/muscle-workout", // your url here

5. Add ./client/.env.production to set the production environment variables

REACT_APP_API = "https://nbysf9yfy1.execute-api.us-east-1.amazonaws.com/dev/graphql" // the endpoint to the AWS cloud front
REACT_APP_PUBLIC_URL = "/muscle-workout" // public url for routing

We also need to add the public url variable to our local .env

REACT_APP_API = "http://localhost:4000/grapql"
REACT_APP_PUBLIC_URL = ""

6. Add basename to your ./client/src/routing.tsx

import React from "react";
import {
  BrowserRouter as Router,
  Switch,
  Route,
} from "react-router-dom";
import MusclePage from './pages/muscle';
import VideoPage from './pages/video';
export default function Routing() {
  return (
    <Router basename={process.env.REACT_APP_PUBLIC_URL}>
      <Switch>
        <Route exact path="/">
          <MusclePage />
        </Route>
        <Route path="/video/:id" component={VideoPage} />
      </Switch>
    </Router>
  );
}

7. Deploy the app to github pages

npm run deploy

8. Test

Make sure your github pages source is set to the gh-pages branch and your app is published.

Congratulations! You have finished the serverless app end to end by using React, Apollo, AWS Lambda and RDS.

The source code is here for your reference and feel free to comment or create issues if you have any questions.

parallax image

Find Your Possible.

Let's Chat