Not so long ago I started exploring server-side rendered single-page applications. Yeah, try saying that three times fast. Building products for startups has taught me SEO is a must if you want an online presence. But, you also want the performance SPAs can provide.
We want the best of both worlds. The SEO boost server-side rendering provides, and the speed of a Single Page Application. Today I'll show you all this while hosting it basically for free in a serverless environment on AWS Lambda.
TL;DR
Let's run through what this tutorial will cover. You can skim through and jump to the section that interest you. Or, be a nerd and keep reading. * whisper * Please be a nerd.
- What're we building?
- Configure and install dependencies
- Build the app with the Serverless Framework and Next.js
- Deploy the app to AWS Lambda
Note: The code we will write is already on GitHub if you need further reference or miss any steps, feel free to check it out. The guys over at Cube.js gave me a quick rundown of React before I started writing this tutorial. They have a serverless analytics framework that plugs nicely into React. Feel free to give it a try.
What're we building?
Well, a blazing-fast React application of course! The cost of every SPA is lousy SEO capabilities though. So we need to build the app in a way to incorporate server-side rendering. Sounds simple enough. We can use Next.js, a lightweight framework for static and server-rendered React.js applications.
To accomplish this we need to spin up a simple Express server and configure the Next app to serve files through Express. It is way simpler than it sounds.
However, from the title you can assume we don't like the word server in my neighborhood. The solution is to deploy this whole application to AWS Lambda! It is a tiny Node.js instance after all.
Ready? Let's get crackin'!
Configure and install dependencies
As always, we're starting with the boring part, setting up the project and installing dependencies.
1. Install the Serverless Framework
In order for serverless development to not be absolute torture, go ahead and install the Serverless framework.
$ npm i -g serverless
Note: If you’re using Linux or Mac, you may need to run the command as sudo
.
Once installed globally on your machine, the commands will be available to you from wherever in the terminal. But for it to communicate with your AWS account you need to configure an IAM User. Jump over here for the explanation, then come back and run the command below, with the provided keys.
$ serverless config credentials \
--provider aws \
--key xxxxxxxxxxxxxx \
--secret xxxxxxxxxxxxxx
Now your Serverless installation knows what account to connect to when you run any terminal command. Let’s jump in and see it in action.
2. Create a service
Create a new directory to house your Serverless application services. Fire up a terminal in there. Now you’re ready to create a new service.
What’s a service you ask? View it like a project. But not really. It’s where you define AWS Lambda functions, the events that trigger them and any AWS infrastructure resources they require, all in a file called serverless.yml.
Back in your terminal type:
$ serverless create --template aws-nodejs --path ssr-react-next
The create command will create a new service. Shocker! But here’s the fun part. We need to pick a runtime for the function. This is called the template. Passing in aws-nodejs
will set the runtime to Node.js. Just what we want. The path will create a folder for the service.
3. Install npm modules
Change into the ssr-react-next folder in your terminal. There should be three files in there, but for now, let's first initialize npm.
$ npm init -y
After the package.json
file is created, you can install a few dependencies.
$ npm i \
axios \
express \
serverless-http \
serverless-apigw-binary \
next \
react \
react-dom \
path-match \
url \
serverless-domain-manager
These are our production dependencies, and I'll go into more detail explaining what they do a bit further down. The last one, called serverless-domain-manager
will let us tie a domain to our endpoints. Sweet!
Now, your package.json
should look something like this.
// package.json
{
"name": "serverless-side-rendering-react-next",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": { // ADD THESE SCRIPTS
"build": "next build",
"deploy": "next build && sls deploy"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^0.18.0",
"express": "^4.16.4",
"next": "^7.0.2",
"path-match": "^1.2.4",
"react": "^16.6.3",
"react-dom": "^16.6.3",
"serverless-apigw-binary": "^0.4.4",
"serverless-http": "^1.6.0",
"url": "^0.11.0",
"serverless-domain-manager": "^2.6.0"
}
}
We also need to add two scripts, one for building and one for deploying the app. You can see them in the scripts
section of the package.json
.
4. Configure the serverless.yml file
Moving on, let's finally open up the project in a code editor. Check out the serverless.yml file, it contains all the configuration settings for this service. Here you specify both general configuration settings and per function settings. Your serverless.yml will be full of boilerplate code and comments. Feel free to delete it all and paste this in.
service: ssr-react-next
provider:
name: aws
runtime: nodejs8.10
stage: ${self:custom.secrets.NODE_ENV}
region: us-east-1
environment:
NODE_ENV: ${self:custom.secrets.NODE_ENV}
functions:
server:
handler: index.server
events:
- http: ANY /
- http: ANY /{proxy+}
plugins:
- serverless-apigw-binary
- serverless-domain-manager
custom:
secrets: ${file(secrets.json)}
apigwBinary:
types:
- '*/*'
customDomain:
domainName: ${self:custom.secrets.DOMAIN}
basePath: ''
stage: ${self:custom.secrets.NODE_ENV}
createRoute53Record: true
# endpointType: 'regional'
# if the ACM certificate is created in a region except for `'us-east-1'` you need `endpointType: 'regional'`
The functions
property lists all the functions in the service. We will only need one function because it will run the Next app and render the React pages. It works by spinning up a tiny Express server, running the Next renderer alongside the Express router and passing the server to the serverless-http module.
In turn, this will bundle the whole Express app into a single lambda function and tie it to an API Gateway endpoint. Under the functions property, you can see a server function that will have a handler named server
in the index.js
file. API Gateway will proxy any and every request to the internal Express router which will then tell Next to render our React.js pages. Woah, that sounds complicated! But it's really not. Once we start writing the code you'll see how simple it really is.
We've also added two plugins, the serverless-apigw-binary
for letting more mime types pass through API Gateway and the serverless-domain-manager
which lets us hook up domain names to our endpoints effortlessly.
We also have a custom
section at the bottom. The secrets
property acts as a way to safely load environment variables into our service. They're later referenced by using ${self:custom.secrets.<environment_var>}
where the actual values are kept in a simple file called secrets.json
.
Apart from that, we're also letting the API Gateway binary plugin know we want to let all types through, and setting a custom domain for our endpoint.
That's it for the configuration, let's add the secrets.json
file.
5. Add the secrets file
Add a secrets.json
file and paste this in. This will keep us from pushing secret keys to GitHub.
{
"NODE_ENV": "production",
"DOMAIN": "react-ssr.your-domain.com"
}
Now, only by changing these values you can deploy different environments to different stages and domains. Pretty cool.
Build the app with the Serverless Framework and Next.js
To build a server-side rendered React.js app we'll use the Next.js framework. It lets you focus on writing the app instead of worrying about SEO. It works by rendering the JavaScript before sending it to the client. Once it's loaded on the client side, it'll cache it and serve it from there instead. You have to love the speed of it!
Let's start by writing the Next.js setup on the server.
1. Setting up the Next.js server(less)-side rendering
Create a file named server.js. Really intuitive, I know.
// server.js
const express = require('express')
const path = require('path')
const dev = process.env.NODE_ENV !== 'production'
const next = require('next')
const pathMatch = require('path-match')
const app = next({ dev })
const handle = app.getRequestHandler()
const { parse } = require('url')
const server = express()
const route = pathMatch()
server.use('/_next', express.static(path.join(__dirname, '.next')))
server.get('/', (req, res) => app.render(req, res, '/'))
server.get('/dogs', (req, res) => app.render(req, res, '/dogs'))
server.get('/dogs/:breed', (req, res) => {
const params = route('/dogs/:breed')(parse(req.url).pathname)
return app.render(req, res, '/dogs/_breed', params)
})
server.get('*', (req, res) => handle(req, res))
module.exports = server
It's pretty simple. We're grabbing Express and Next, creating a static route with express.static
and passing it the directory of the bundled JavaScript that Next will create. The path is /_next
, and it points to the .next
folder.
We'll also set up the server-side routes and add a catch-all route for the client-side renderer.
Now, the app needs to be hooked up to serverless-http
and exported as a lambda function. Create an index.js
file and paste this in.
// index.js
const sls = require('serverless-http')
const binaryMimeTypes = require('./binaryMimeTypes')
const server = require('./server')
module.exports.server = sls(server, {
binary: binaryMimeTypes
})
As you can see we also need to create binaryMimeTypes.js
file to hold all the mime types we want to enable. It'll just a simple array which we pass into the serverless-http
module.
// binaryMimeTypes.js
module.exports = [
'application/javascript',
'application/json',
'application/octet-stream',
'application/xml',
'font/eot',
'font/opentype',
'font/otf',
'image/jpeg',
'image/png',
'image/svg+xml',
'text/comma-separated-values',
'text/css',
'text/html',
'text/javascript',
'text/plain',
'text/text',
'text/xml'
]
Sweet, that's it regarding the Next.js setup. Let's jump into the client-side code!
2. Writing client-side React.js
In the root of your project create three folders named, components
, layouts
, pages
. Once inside the layouts
folder, create a new file with the name default.js
, and paste this in.
// layouts/default.js
import React from 'react'
import Meta from '../components/meta'
import Navbar from '../components/navbar'
export default ({ children, meta }) => (
<div>
<Meta props={meta} />
<Navbar />
{ children }
</div>
)
The default view will have a <Meta />
component for setting the metatags dynamically and a <Navbar/>
component. The { children }
will be rendered from the component that uses this layout.
Now add two more files. A navbar.js
and a meta.js
file in the components
folder.
// components/navbar.js
import React from 'react'
import Link from 'next/link'
export default () => (
<nav className='nav'>
<ul>
<li>
<Link href='/'>Home</Link>
</li>
<li>
<Link href='/dogs'>Dogs</Link>
</li>
<li>
<Link href='/dogs/shepherd'>Only Shepherds</Link>
</li>
</ul>
</nav>
)
This is an incredibly simple navigation that'll be used to navigate between some cute dogs. It'll make sense once we add something to the pages
folder.
// components/meta.js
import Head from 'next/head'
export default ({ props = { title, description } }) => (
<div>
<Head>
<title>{ props.title || 'Next.js Test Title' }</title>
<meta name='description' content={props.description || 'Next.js Test Description'} />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<meta charSet='utf-8' />
</Head>
</div>
)
The meta.js
will make it easier for us to inject values into our meta tags. Now you can go ahead and create an index.js
file in the pages
folder. Paste in the code below.
// pages/index.js
import React from 'react'
import Default from '../layouts/default'
import axios from 'axios'
const meta = { title: 'Index title', description: 'Index description' }
class IndexPage extends React.Component {
constructor (props) {
super(props)
this.state = {
loading: true,
dog: {}
}
this.fetchData = this.fetchData.bind(this)
}
async componentDidMount () {
await this.fetchData()
}
async fetchData () {
this.setState({ loading: true })
const { data } = await axios.get(
'https://api.thedogapi.com/v1/images/search?limit=1'
)
this.setState({
dog: data[0],
loading: false
})
}
render () {
return (
<Default meta={meta}>
<div>
<h1>This is the Front Page.</h1>
<h3>Random dog of the day:</h3>
<img src={this.state.dog.url} alt='' />
</div>
</Default>
)
}
}
export default IndexPage
The index.js
file will be rendered on the root path of our app. It calls a dog API and will show a picture of a cute dog.
Let's create more routes. Create a sub-folder called dogs
and create an index.js
file and a _breed.js
file in there. The index.js
will be rendered at the /dogs
route while the _breed.js
will be rendered at /dogs/:breed
where the :breed
represents a route parameter.
Add this to the index.js
in the dogs
directory.
// pages/dogs/index.js
import React from 'react'
import axios from 'axios'
import Default from '../../layouts/default'
const meta = { title: 'Dogs title', description: 'Dogs description' }
class DogsPage extends React.Component {
constructor (props) {
super(props)
this.state = {
loading: true,
dogs: []
}
this.fetchData = this.fetchData.bind(this)
}
async componentDidMount () {
await this.fetchData()
}
async fetchData () {
this.setState({ loading: true })
const { data } = await axios.get(
'https://api.thedogapi.com/v1/images/search?size=thumb&limit=10'
)
this.setState({
dogs: data,
loading: false
})
}
renderDogList () {
return (
<ul>
{this.state.dogs.map((dog, key) =>
<li key={key}>
<img src={dog.url} alt='' />
</li>
)}
</ul>
)
}
render () {
return (
<Default meta={meta}>
<div>
<h1>Here you have all dogs.</h1>
{this.renderDogList()}
</div>
</Default>
)
}
}
export default DogsPage
And, another snippet in the _breed.js
file in the dogs
folder.
// pages/dogs/_breed.js
import React from 'react'
import axios from 'axios'
import Default from '../../layouts/default'
class DogBreedPage extends React.Component {
static getInitialProps ({ query: { breed } }) {
return { breed }
}
constructor (props) {
super(props)
this.state = {
loading: true,
meta: {},
dogs: []
}
this.fetchData = this.fetchData.bind(this)
}
async componentDidMount () {
await this.fetchData()
}
async fetchData () {
this.setState({ loading: true })
const reg = new RegExp(this.props.breed, 'g')
const { data } = await axios.get(
'https://api.thedogapi.com/v1/images/search?size=thumb&has_breeds=true&limit=50'
)
const filteredDogs = data.filter(dog =>
dog.breeds[0]
.name
.toLowerCase()
.match(reg)
)
this.setState({
dogs: filteredDogs,
breed: this.props.breed,
meta: { title: `Only ${this.props.breed} here!`, description: 'Cute doggies. :D' },
loading: false
})
}
renderDogList () {
return (
<ul>
{this.state.dogs.map((dog, key) =>
<li key={key}>
<img src={dog.url} alt='' />
</li>
)}
</ul>
)
}
render () {
return (
<Default meta={this.state.meta}>
<div>
<h1>Dog breed: {this.props.breed}</h1>
{this.renderDogList()}
</div>
</Default>
)
}
}
export default DogBreedPage
As you can see in the Default
component we're injecting custom meta tags. It will add custom fields in the <head>
of your page, giving it proper SEO support!
Note: If you're stuck, here's what the code looks like in the repo.
Let's deploy it and see if it works.
Deploy the app to AWS Lambda
At the very beginning, we added a script to our package.json
called deploy
. It'll build the Next app and deploy the serverless service as we specified in the serverless.yml
.
All you need to do is run:
$ npm run deploy
The terminal will return output with the endpoint for your app. We also need to add the domain for it to work properly. We've already added the configuration in the serverless.yml
but there's one more command we need to run.
$ sls create_domain
This will create a CloudFront distribution and hook it up to your domain. Make sure that you've added the certificates to your AWS account. It usually takes around 20 minutes for AWS to provision a new distribution. Rest your eyes for a moment.
Once you're back, go ahead and deploy it all again.
$ npm run deploy
It should now be tied up to your domain. Here's what it should look like.
Nice! The app is up-and-running. Go ahead and try it out.
How to gain insight into your system?
The #1 problem with all serverless applications are their distributed nature. Plain and simple, it's impossibly hard to have an overview of all the things going on. Not to mention how hard it is to debug when something goes wrong.
To calm my fears I use Tracetest. It gives you end-to-end testing & debugging powered by OpenTelemetry.
Thankfully, there's detailed documentation, which makes the onboarding process a breeze. Go ahead and follow the Quick Start guide. Don't forget to come back here though. 😄
Wrapping up
This walkthrough was a rollercoaster of emotions! It gives you a new perspective into creating fast and performant single-page apps while at the same time keeping the SEO capabilities of server-rendered apps. However, with a catch. There are no servers you need to worry about. It's all running in a serverless environment on AWS Lambda. It's easy to deploy and scales automatically. Doesn't get any better.
If you got stuck anywhere take a look at the GitHub repo for further reference, and feel free to give it a star if you want more people to see it on GitHub.
adnanrahic / serverless-side-rendering-react-next
Sample repo for setting up Next and React on AWS Lambda with the Serverless Framework.
Serverless-Side Rendering React Next
Sample repo for setting up Next and React on AWS Lambda with the Serverless Framework.
If you want to read some of my previous serverless musings head over to my profile or join my newsletter!
Or, take a look at a few of my articles right away:
- A crash course on Serverless with AWS - Building APIs with Lambda and Aurora Serverless
- A crash course on Serverless with AWS - Image resize on-the-fly with Lambda and S3
- A crash course on Serverless with AWS - Triggering Lambda with SNS Messaging
- A crash course on serverless-side rendering with Vue.js, Nuxt.js and AWS Lambda
- Building a serverless contact form with AWS Lambda and AWS SES
- A crash course on Serverless APIs with Express and MongoDB
- Solving invisible scaling issues with Serverless and MongoDB
- How to deploy a Node.js application to AWS Lambda using Serverless
- Getting started with AWS Lambda and Node.js
- A crash course on securing Serverless APIs with JSON web tokens
- Migrating your Node.js REST API to Serverless
- Building a Serverless REST API with Node.js and MongoDB
- A crash course on Serverless with Node.js
I also highly recommend checking out this article about Next.js, and this tutorial about the serverless domain manager.
Hope you guys and girls enjoyed reading this as much as I enjoyed writing it. If you liked it, slap that tiny heart so more people here on dev.to will see this tutorial. Until next time, be curious and have fun.
Disclaimer: Tracetest is sponsoring this blogpost. Use observability to reduce time and effort in test creation + troubleshooting by 80%.
Top comments (27)
Great tutorial - thank you! I finished the tutorial and successfully deployed, but am getting errors when navigating to the site. When navigating to https://[unique id].execute-api.us-east-1.amazonaws.com/dev/, I get the message {"message": "Internal server error"}. One nuance is that I removed the customDomain section, as I was hoping to use the AWS endpoint.
One thing I did when testing, is I added "dev": "next" to the scripts object in the package.json, so when I run npm run dev, I get the correct information displaying at localhost:3000, localhost:3000/dogs, etc.
Also, I added the code below to the server.js file, and when I navigate to https://[unique id].execute-api.us-east-1.amazonaws.com/dev/test, I get the 'Hello World!' message displaying, as expected:
server.get('/test', (req, res) => {
res.send('Hello World!');
});
The full server.js file is below. The server.get function with the res.send works, but the server.get functions with the app.render always throw an error.
const express = require('express');
const path = require('path');
const dev = process.env.NODE_ENV !== 'production';
const next = require('next');
const pathMatch = require('path-match');
const app = next({ dev });
const handle = app.getRequestHandler();
const { parse } = require('url');
const server = express();
const route = pathMatch();
server.use('/next', express.static(path.join(_dirname, '.next')));
server.get('/test', (req, res) => {
res.send('Hello World!');
});
server.get('/', (req, res) => {
app.render(req, res, '/')
});
server.get('/dogs', (req, res) => {
app.render(req, res, '/dogs')
});
server.get('/dogs/:breed', (req, res) => {
const params = route('/dogs/:breed')(parse(req.url).pathname)
return app.render(req, res, '/dogs/_breed', params)
});
server.get('*', (req, res) => handle(req, res))
module.exports = server;
Any insight would be greatly appreciated! Let me know if I can provide any additional information. Sorry if I'm doing anything real obvious, as I am completely new to Next.js and still fairly beginner with AWS.
I would add the same exact comment. I get Internal server error. If I try to add the /test get route, it works fine. If I try next locally it displays fine on localhost:3000. I myself has not even removed the customDomain section as I have a domain on Route 53 for that.
I have just tried copying the repo provided in the article and deploying it to my domain. Still the same Internal server error.
I get the same error when deploying the app to AWS.
Error:
{
message: "Internal server error"
}
The routes work fine locally, except the dynamic _breed route which results in a 404.
I've tried everything myself and also cloned the git repo as is to test. Both give the exact same result mentioned above. I have a custom domain linked via "sls create_domain" command.
for local development and testing:
npm run dev will run it has a next app without serverless (only pages are available, no serverless functionality written in server.js). You need to use serverless-offline github.com/dherault/serverless-off...
Same here. I'm getting the internal server error and the issue has to do with calling app.render. Further research leads me to believe that the problem stems from not calling app.prepare before configuring the server.
Hi, this raised a couple a questions with me:
serverless
provide in this scenario? What are the alternatives(aws cli?terraform?idk.)?Hey, glad to answer.
Hope this answers your questions. Feel free to let me know if you have any more. :D
This was the error I received after I ran 'npm run deploy' for the first time.
I have a domain name that ends in .info
Could that be the cause?
Hi,
Once I deploy this, I'm able to load the front page, but all the next resources (*.js) come back as 403, any idea why?
Thanks for your help,
FWIW, you're probably seeing issue from not deploying with custom domain and needing to have the stage in the path to access the resource, e.g.
https://XXXXX.execute-api.us-east-1.amazonaws.com/production/_next/static/YYYYY/pages/index.js
is needed buthttps://XXXXX.execute-api.us-east-1.amazonaws.com/_next/static/YYYYY/pages/index.js
is what the express response actually renders.This explains some of issue and attempts to make it work github.com/dougmoscrop/serverless-...
as well as this: github.com/zeit/next.js/issues/6447
Can you try cloning the repo and deploying it? If that works, there has to be a strange issue somewhere in your code. Compare your project to the cloned repo. Hopefully, that'll help you out. :)
It doesn't work when I clone it from the repo either. The only thing I've changed were the values in secret.json
On local routes work fine, except for the dynamic "_breed" route. It results in 404 Not Found.
On deploying to AWS all routes result in message: "Internal server error" ... you can check it out at ecom.ideamappers.com
That does not work. It gives and internal server error as soon as you try using app.render.
Hi,
I have exactly the same issue.
Where you able to solve it?
Thanks
I have stumbled on a few of your tutorials Adnan and they are all awesome. I actually read through all of this before realising it was you (I got into Serverless when I read your Express, Mongo + Serverless tutorial on hackernoon). NextJS is great and I am stoked that it is just as easy to deploy to Lambda. Thank you!
Hey Gary! Wow, thanks for the kind words. I'm glad I can help the community understand serverless architectures. Stay tuned for more tutorials. :)
Have you done any performance testing on this setup? I would be interested to know some numbers like TTFB and others.
I haven't actually. I'd be stoked to know the performance too. Once I find the time, I'll surely check it out. If you get around to doing it yourself, please let me know! :D
I saw that Next.js 8 was released after you published this article. It has a section about how serverless is now supported. Should I do anything differently because of this?
Hey, this is great thanks! For those following the tutorial and copying and pasting the code, there is a typo in index.js.. it is missing
)
. FYIOh wow, nice catch! Thanks for that. I've gone in and fixed the typo. I'm glad you like the tutorial. :)
Is it possible to display images from the static folder once the site is deployed? I added an image for a logo to the static folder and am displaying it in the header component. It shows while I'm developing on localhost, but when I deploy to AWS it shows as a broken image.
Here's my code:
github.com/joshglazer/freetimeupda...
I figured this out myself. In case anyone's curious, you can fix this by adding the following line of code to your server.js file.
server.use('/static', express.static('static'))
Great tutorial - thanks alot! I am new to the NextJS x AWS Lambda stack. When I call the function url, all I get is a random string like: PCFET0NUWVBFIGh0bWw+CjxodG1sIGxhbmc9ImVuIj4KPGhlYWQ+CjxtZXRhIGNoYXJzZXQ9InV0Zi04Ij4KPHRpdGxlPkVycm9yPC90aXRsZT4KPC9oZWFkPgo8Ym9keT4KPHByZT5DYW5ub3QgR0VUIC88L3ByZT4KPC9ib2R5Pgo8L2h0bWw+Cg==
Locally everything works fine and I can see that NextJS is rendering my pages. However, on lambda all I get is that random string.
Do you know why this could be happening? Thanks alot :D