Serve a Create-React-App Application With Nest.Js for Better SEO and Social Sharing.

Mor Kadosh
The Startup
Published in
13 min readJul 23, 2020

A few months ago, I started working on an exciting side project. I chose to work with create-react-app on the client-side, and Nest.js on the server, both great solutions for my needs.

After a few months of the product running on the wild, I turned to improve my websites’ SEO metrics.

My journey began as I started to follow every piece of advice I found in this field, which was pretty new to me.

From improving the Lighthouse to a near-perfect score, and to add unique meta tags and title for every page of the app. As some of my app’s pages are user-content based, those meta tags often were totally dynamic.

To do so, I used a great library called react-helmet which was super easy to implement.

The problem — Google and the social media platforms such as Facebook and Twitter couldn’t crawl those dynamic metadata tags. It is true that Google’s crawlers improved tremendously and can even execute Javascript nowadays, but It never worked to me. Even after I tried to follow the techniques suggested in this post.

Than I began exploring the option of migrating to Next.js which is without a doubt the best and most robust solution for SSR with React (server side rendering) there is out there.

After spending a handful of hours trying to migrate my app, it started to look as a bad direction for me. I’m not saying it wasn’t possible, but probably not worth it in my case.

I than found the following vague documentation page in the official create-react-app website: https://create-react-app.dev/docs/title-and-meta-tags/#generating-dynamic-meta-tags-on-the-server which I didn’t know before.

In short, CRA suggests to edit the static index.html by adding placeholders where the static meta and title tags used to be, and then serve the app on the server.

As I couldn’t find any proper tutorial or guidance that shows how to do that with CRA & Nest.js stack, I decided to write one myself.

In this guide, we will create a simple recipes app with CRA & Nest, and we will implement some techniques that will allow crawlers to get the dynamic metadata from our app, and display it correctly. After that, we will create a dynamic sitemap for our app, to make our SEO even better.

Agenda

  1. Create a simple app with CRA and Nest.js.
  2. Serve the app from Nest.js and add dynamic meta tags
  3. Make the app more discoverable by implementing a dynamic sitemap

The Server

If you don’t have the Nest.js cli, run:

yarn global add @nestjs/cli# ornpm install -g @nestjs/cli

We’ll start of by creating the server side of our app:

nest new cookbook-server

Let’s create a simple RecipeModule that will contain all the pieces needed to fetch recipes from our server:

nest g module recipe
nest g service recipe
nest g controller recipe

For simplicity, we will use static server data, but real-world apps will probably use a database or any other source of data. In this tutorial I used https://github.com/raywenderlich/recipes/blob/master/Recipes.json

And the code:

recipe.module.ts:

import { Module } from '@nestjs/common';
import { RecipeService } from './recipe.service';
import { RecipeController } from './recipe.controller';
@Module({
providers: [RecipeService],
controllers: [RecipeController]
})
export class RecipeModule {}

recipe.controller.ts:

import { Controller, Get, NotFoundException, Param } from '@nestjs/common';
import { RecipeService } from './recipe.service';
@Controller('api/recipe')
export class RecipeController {
constructor(private readonly recipeService: RecipeService) {}
@Get()
public async getAll() {
return this.recipeService.getAllRecipes();
}
@Get(':name')
public async getOne(@Param('name') name: string) {
const recipe = this.recipeService.getRecipeByName(name);
if (!recipe) {
throw new NotFoundException();
}
return recipe;
}
}

Note: I set the controller prefix to be api/recipe.

Ideally, we would use Nest’s setGlobalPrefix which gives us the ability to prefix all api paths automatically.

Later we will find out that using setGlobalPrefix is not suitable for us since we will create a non-api controller, and currently there’s no way to exclude this global prefix for specific controllers.

Theres’ an open issue for that: https://github.com/nestjs/nest/issues/963

recipe.service.ts:

import { Injectable } from '@nestjs/common';
import data from './recipe.data';

@Injectable()
export class RecipeService {
public getAllRecipes() {
return data;
}

public getRecipeByName(name: string) {
return data.find(recipe => recipe.name === name);
}
}

And that’s it…for now.

We got our recipe cookbook up and running. Let’s jump to the client side.

The Client

In this section we will not implement the client app, but assume it’s existence.

The app we’ll work with 2 routes: recipe list and recipe page:

import React from 'react';
import './App.css';
import { RecipeList } from './RecipeList';
import {Switch, Route} from 'react-router';
import {BrowserRouter} from 'react-router-dom';
import RecipePage from "./RecipePage";
function App() {
return (
<div className="App">
<BrowserRouter>
<Switch>
<Route path={'/recipe/:name'} exact component={RecipePage} />
<Route path={'/'} component={RecipeList} />
</Switch>
</BrowserRouter>
</div>
);
}

export default App;

The exact implementation details of each piece/component here is not the main focus of this article, so I will leave it to the reader.

Now, as our app is deployed, and our users are happy, John, who really really liked our Pizza recipe, wants to share it with his friends on Facebook.

So John goes to the page, copies the link and pastes it on Facebook. What will he see?

To answer this question, we’ll have to be introduced to the open graph protocol.

In short, this protocol uses meta tags on our webpage in order to generate meaningful shareable data. For example, if you ever shared a news article from CNN for example, it looks like this:

The first solution I came across to was react-helmet which allows us to add raw html tags to our web page from within React components. So at the time I thought, if Google can run JS, and I can easily implement react-helmet on my side, it could be enough. Is it?

Searching online for a react solution for dynamic meta tags, you’ll probably come across react-helmet.

react-helmet is a great and easy to use library, that simply allows you to manage and change the document head. So, if we use it in our recipe page, it should add them open-graph and title tags to our page. Sound perfect right?

Let’s try give it a try.

Assuming this code is implemented in the Recipe Page:

<Helmet>
<title>My Recipes - {data.name}</title>
<meta property="og:title" content={`My Recipes - ${data.name}`} />
<meta property="og:description" content={data.description} />
<meta property="og:image" content={data.imageURL} />
</Helmet>

Easy.

Now, let’s try to crawl a sample page with open graph.

To do so locally, I used a chrome extension called Open Graph Preview.

No Pizza for John🍕😥

But what happens if we use the actual browser and visit the same URL?

At this point, I came across the following post that demonstrates how to solve this exact problem. TL;DR, if you manage to render your page completely in less then ~5 seconds, then your’e all set. Not my case though. For me, it didn’t work.

I wish it worked for me as well.

So are we done with react-helmet? no sir.

As our final result will not render React on the server like Next and similar do, but will use a different technique, most of our code will still run on the client side.

Therefore, it’s still important to set the meta tags and title for every page of our app, so our users will be able to see them (mostly the title in this case).

Remember: you are building an app for users, not for search engines.

Generating the meta tags on the server

Back to the official CRA documentation: https://create-react-app.dev/docs/title-and-meta-tags/

To some it up, CRA team suggests that this problem could be solved by implementing some changes to our web server.

Now we will see how it is possible to be made with Nest.

Configuration

As this article does not focus on how to build a complete application from scratch, I will assume that your app can handle configuration loading.

In the code examples below, I will make a use of a configService which loads an environment file (.env ) into memory, and make every configuration accessible via get method. Similarly to the official docs.

The only configuration I added for this article is the build path of the create-react-app. For instance:

CLIENT_BUILD_PATH=/var/www/build

Serving the static assets

Now we’ll create a new module, controller and service to handle our client app.

Side Note: Why not using the default app.module for that? keep reading to find out.

I named this module client but feel free to name it better:

nest g module client
nest g controller client
nest g service client

client.controller.ts:

import { Controller, Get } from '@nestjs/common';
import { ClientService } from './client.service';

@Controller()
export class ClientController {
constructor(private readonly clientService: ClientService) {}

@Get('*')
public async get() {
return this.clientService.getApp();
}
}

NOTE! we used a wildcard route here. make sure that ClientController is loaded last. As order does matter when it comes to routes, we don’t want every request to be caught here.

client.service.ts

import { Injectable } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';

@Injectable()
export class ClientService {
public async getApp() {
const basePath = this.configService.get('CLIENT_BUILD_PATH');
const filePath = path.resolve(path.join(basePath, 'index.html'));
return new Promise((resolve, reject) => {
fs.readFile(filePath, 'utf8', (err: NodeJS.ErrnoException, data: string) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}
}

We’re set to keep going, but 2 notes first:

  1. Our client controller is not prefixed with as I would like to take advantage of the / route. You can do otherwise.
  2. If you use a response interceptor, you will probably need to change it in such way that only responses from API calls are intercepted, for example:
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
const response = context.switchToHttp().getResponse();
const arg = context.getArgByIndex(0);
if (arg && !arg.route.path.includes('api')) {
return next.handle();
}
.....
}

So what happens now if we open the app in the browser?

Well, not much:

Well, we could pretty much expect that, no?

As our index.html file requests for the static assets (js, css, manifest), and since our server does not have an appropriate way of dealing them, they failed to load.

One possible technique we can apply here is by adding a middleware to our client module:

Create a file client.middleware.ts:

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';
import { AppService } from './app.service';
@Injectable()
export class ClientMiddleware implements NestMiddleware {
constructor(private readonly clientService: ClientService) {}
async use(req: Request, res: Response, next: () => void) {
if (/[^\\/]+\.[^\\/]+$/.test(req.path)) {
const file = getAssetPath(req.path);
res.sendFile(file, (err) => {
if (err) {
res.status(err.status).end();
}
});
} else {
return next();
}
}
}

Where getAssetPath is simply a utility function that will help us find the asset on the server:

import * as path from 'path'
...
getAssetPath(url: any) {
const basePath = this.configService.get('CLIENT_BUILD_PATH');
return path.join(basePath, url);
}

Note: I used a pretty simple way to check if the requested resource is a file or not, feel free to improve it.

@Module({
providers: [ClientService],
controllers: [ClientController]
})
export class ClientModule{
configure(consumer: MiddlewareConsumer) {
consumer
.apply(ClientMiddleware)
.forRoutes(ClientController);
}
}

Let’s test our app again:

It works!

Now we’ll navigate to a recipe page and see what happens : http://localhost:5000/recipe/Big%20Night%20Pizza

As we can see, the title and meta tags did change. But does it mean we are done? probably not.

This is the point when some of the crawlers might read your meta tags, but it is not guaranteed. From my own experience with a real-world app, they didn’t.

A quick test with open graph shows that our work is not finished yet:

As you can imagine, the crawler didn’t run our Javascript code, but only fetched the static tags in our index.html file.

Then as advised by the CRA docs, we’ll replace the static tags with some placeholders, which will be then replaced with real values:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="__PAGE_DESCRIPTION__"
/>

<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>__PAGE_TITLE__</title>
<meta property="og:title" content="__PAGE_TITLE__" />
<meta property="og:description" content="__PAGE_DESCRIPTION__" />
<meta property="og:image" content="__PAGE_IMAGE__" />

</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

Building the client app withyarn build, and crawling the app once again now yields:

Ok, so now all we have to do is to parse the served index.html file, fetch the recipe metadata and replace the placeholders in the file. We’ll do so by changing the implementation of getApp to accept some metadata attributes, and replace the placeholders with them:

client.service.ts

interface IPageMetadata {
title?: string;
description?: string;
image?: string;
}
...
public async getApp(pageMetadata: IPageMetadata = DEFAULT_META) {
const basePath = this.configService.get('CLIENT_BUILD_PATH');
const filePath = path.resolve(path.join(basePath, 'index.html'));
return new Promise((resolve, reject) => {
fs.readFile(filePath, 'utf8', (err: NodeJS.ErrnoException, data: string) => {
if (err) {
reject(err);
} else {
data = data.replace(/__PAGE_TITLE__/g, pageMetadata.title || DEFAULT_TITLE);
data = data.replace(/__PAGE_DESCRIPTION__/g, pageMetadata.description || DEFAULT_DESCRIPTION);
data = data.replace(/__PAGE_IMAGE__/g, pageMetadata.image || DEFAULT_IMAGE);

resolve(data);
}
});
});
}

Now let’s test it by crawling the main page:

That’s a progress.

Now, we can simply use the beauty and simplicity of Nest, to do the same thing for recipes.

We first need to import the recipes module into our client module, and inject recipe.service to our client.controller :

client.module.ts

@Module({
imports: [
RecipeModule,
...
})

client.controller.ts

mport { Controller, Get, Param } from '@nestjs/common';
import { ClientService } from './client.service';
import { RecipeService } from '../recipe/recipe.service';

@Controller()
export class ClientController {
constructor(
private readonly clientService: ClientService,
private readonly recipeService: RecipeService,
) {}

@Get('recipe/:name')
public async getRecipe(@Param('name') name: string) {
const recipe = await this.recipeService.getRecipeByName(name);
const meta = {
title: recipe.name,
description: recipe.description,
image: recipe.imageURL,
};

return this.clientService.getApp(meta);
}

@Get('*')
public async get() {
return this.clientService.getApp();
}
}

(this is a mock code, feel free to make it more organised and “safe”).

Time to test:

Generating a dynamic Sitemap

A sitemap is an XML file that contains a list of pages of a website. Having a sitemap in your app can help search engines discover your pages more easily.

In our case, we would like to allow search engines discover our recipe pages (in addition to every static page).

You can create a separate module and controller for that, but in order to keep this article simple I will only create a service, and use the existing client.controller :

nest g service sitemap

Also, make sure to install xmlbuilder which will assist us to build the map:

yarn add xmlbuilder

Our sitemap will contain the page url and lastmod which indicates when this page was modified, and might help search engines to know if they should crawl it again. For more possible entries, checkout this webiste.

Let’s add an appropriate handle inclient.controller.ts

@Get('sitemap.xml')
@Header('Content-Type', 'application/xml')
public async getSiteMap() {
return this.sitemapService.getSiteMap();
}

And here’s a possible implementation forsitemap.service.ts:

import { Injectable } from '@nestjs/common';
import * as builder from 'xmlbuilder';
import { RecipeService } from '../recipe/recipe.service';
import { XMLElement } from 'xmlbuilder';
@Injectable()
export class SitemapService {
constructor(private readonly recipeService: RecipeService) {}
public async getSitemap() {
const recipes = await this.recipeService.getAllRecipes();
return this.buildXML(recipes);
}
private async buildXML(recipes: IRecipe[]) {
const xml = builder
.create('urlset')
.att('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9');
recipes.forEach(recipe => {
this.buildEntry(xml, `recipe/${recipe.name}`, recipe.uploadDate);
});
return xml.end({ pretty: true });
}
private buildEntry(xml: XMLElement, uri: string, date: string) {
const baseUrl = 'http://localhost:5000';
xml
.ele('url')
.ele('loc', `${baseUrl}/${uri}`)
.up()
.ele('lastmod', date)
.up();
}
}

And before we keep going any further, let’s test this route on the browser http://localhost:5000/sitemap.xml :

Remember the middleware we applied before? well, seems like it still works :)

Let’s modify it a bit to exclude the request for sitemap.xml:

export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(ClientMiddleware)
.exclude({ path: '/sitemap.xml', method: RequestMethod.GET })
.forRoutes(ClientController);
}
}

Testing in the browser:

And all you have left to do is to submit the sitemap to Google or any other service.

Conclusion

We learned how we can serve our react app, created with create-react-app using Nest, generate dynamic meta tags for every page, without actually migrating to a SSR designated frameworks like Next, which can be very costly for some apps. and even generating a dynamic sitemap to make those pages discoverable more easily.

I do strongly recommend you to think about all the options available when starting a new project. If SEO is a big deal for you, you should consider using frameworks like Next at the first place. In this article I presented some solutions for common SEO problems for those of you who already implemented their app with Nest and CRA.

You should also improve your SEO metrics with gzip compression, which will eventually load your app faster, and is pretty easy to achieve with @nest/serve-static and the express/compression modules.

Of course, most of the techniques implemented here with Nest can easily be implemented with a “plain” express server.

--

--