Implementando SSR com React e Express
23 de setembro de 2023Fala galera, hoje estou com a ideia de criar meu próprio servidor de React utilizando ExpressJS. A ideia é criar a lógica de SSR com as apis do React e utilizar o Express para cuidar das rotas e do servidor. Vamos nessa?
Se você quiser entender os conceitos que estamos utilizando aqui, recomendo que leia o post sobre o Universo de renderização do React.
Criando o projeto
Bom galera criei uma pasta chamada react-ssr
e comecei rodando yarn init
para criar o projeto. Depois disso instalei as dependências do express e do react com yarn add express react react-dom
.
Typescript
Para utilizar o typescript no projeto, instalei as dependências com:
BASHyarn add -D typescript @types/react @types/react-dom @types/express
Depois disso criei o arquivo tsconfig.json
com o comando npx tsc --init
. As minhas configurações do typescript ficaram assim:
JSON{"compilerOptions": {"target": "ESNext","jsx": "react","module": "CommonJS","esModuleInterop": true,"forceConsistentCasingInFileNames": true,"skipLibCheck": true}}
Testando a instalação
Agora para testar a instalação vamos fazer um hello world. Crie o arquivo index.html
com o seguinte código:
HTML<!DOCTYPE html><html lang="pt-br"><head><title>Meu React App</title></head><body><div id="root"></div></body></html>
Observe que nosso HTML é bem semelhante ao que vem na pasta
public
de uma instalação feita comcreate-react-app
.
Agora vamos criar nosso primeiro componente React. Crie o arquivo App.tsx
e cole o código abaixo:
TSXimport React from 'react'export const App = () => {return (<div><h1>Hello world</h1></div>);}
Por fim vamos criar o arquivo index.ts
que será o arquivo principal do nosso servidor React com Express, cole o seguinte código:
TSimport path from "path";import fs from "fs";import React from "react";import ReactDOMServer from "react-dom/server";import express from "express";import { App } from "./App";const app = express();app.get("/", (req, res) => {const appContent = ReactDOMServer.renderToString(React.createElement(App));const indexFile = path.resolve("./index.html");fs.readFile(indexFile, "utf8", (err, data) => {if (err) {return res.status(500).send("Não foi possível carregar o app.");}return res.send(data.replace('<div id="root"></div>', `<div id="root">${appContent}</div>`));});});app.listen(8080, () => {console.log(`App rodando na porta ${8080}`);});
Para testar vamos rodar npx ts-node index.ts
e acessar o localhost:8080
. Se tudo deu certo, você deve ver o hello world na tela:

Até esse momento criamos uma implementação básica simulando um SSR. Basicamente a gente está pegando um HTML base que é nosso index.html e substituindo o conteúdo da div root pelo conteúdo do nosso componente App que foi gerado utilizando o
ReactDOMServer.renderToString
.
Hidratação do HTML
Nesse ponto já temos uma implementação básica de SSR, mas ainda não temos a hidratação, ou seja, até aqui o React está 100% no servidor, se a gente adicionar um evento no botão, utilizando um useState
por exemplo, ele não vai funcionar. Então vamos fazer a hidratação do nosso HTML. Vamos começar criando um componente React que utiliza um hook client-side para nosso teste. Altere o arquivo App.tsx
para o seguinte:
TSXimport React from 'react'export const App = () => {const [state, setState] = React.useState(0);const count = () => {const newState = state + 1setState(newState);console.log(newState)}return (<div><h1>Counter: {state}</h1><button onClick={count}>Counter</button></div>);}
Instalando e configurando o webpack
O Webpack é uma ferramenta que nos permite fazer o bundling do nosso código, ou seja, ele vai pegar todos os nossos arquivos e juntar em um só arquivo. Isso é muito útil para o browser, pois ele só precisa fazer uma requisição para o servidor e não várias. Nesse caso vamos utilizar o webpack para gerar um arquivo bundle.js
que vai conter todo o nosso código React em Javascript.
Antes de tudo vamos a uma pequena reorganização dos nossos arquivos:
- Vamos mover o arquivo
App.tsx
para a pastasrc/client/App.tsx
- O arquivo
index.ts
para a pastasrc/server/index.ts
.
Para instalar as dependências do webpack rode o comando:
BASHyarn add --dev ts-loader webpack webpack-cli
Agora vamos criar o arquivo webpack.config.js
com o seguinte conteúdo:
JSconst path = require("path");const clientConfig = {mode: "development",target: "web",entry: "./src/client/index.tsx",module: {rules: [{test: /\.tsx?$/,use: "ts-loader",exclude: /node_modules/,},],},resolve: {extensions: [".tsx", ".ts", ".js"],},output: {filename: "bundle.js",path: path.resolve(__dirname, "dist/public"),},};const serverConfig = {mode: "development",entry: "./src/server/index.ts",target: "node",module: {rules: [{test: /\.tsx?$/,use: "ts-loader",exclude: /node_modules/,},],},resolve: {extensions: [".tsx", ".ts", ".js"],},output: {filename: "index.js",path: path.resolve(__dirname, "dist"),},};module.exports = [clientConfig, serverConfig];
Estamos configurando o webpack para gerar dois arquivos, um para o client e outro para o server. O arquivo do client vai ser gerado na pasta
dist/public
e o do server na pastadist
.
Melhorando o ambiente de desenvolvimento
Para facilitar nossa vida vamos instalar duas libs que são ótimas no ambiente de desenvolvimento. A primeira é a nodemon
que vai ficar observando nossos arquivos e reiniciando o servidor sempre que houver alguma alteração. A segunda é a concurrently
que vai nos permitir rodar o webpack e o nodemon ao mesmo tempo utilizando um só comando. Para instalar as dependências rode o comando:
BASHyarn add --dev nodemon concurrently
Com as libs instaladas, bora criar nosso primeiro script. No seu package.json
, adicione:
JSON"scripts": {"dev": "webpack && concurrently \"webpack --watch\" \"nodemon dist\""},
E seu tudo deu certo, agora é só rodar yarn dev
e temos um counter funcionando:
Streaming SSR
Vamos dar um passo além e fazer o streaming do SSR, funcionalidade que é o coração do React Server Components. Primeiro vamos alterar nossa rota do express para funcionar como um socket e nossa resposta vai ser um stream. Para isso vamos utilizar o método ReactDOMServer.renderToPipeableStream
. Altere o arquivo src/server/index.ts
para o seguinte:
TS// ...app.get('/', (req, res) => {res.socket.on('error', (error) => console.log('Fatal', error));let didError = false;const stream = ReactDOMServer.renderToPipeableStream(React.createElement(App),{bootstrapScripts: ['/bundle.js'],onShellReady: () => {res.statusCode = didError ? 500 : 200;res.setHeader('Content-type', 'text/html');stream.pipe(res);},onError: (error) => {didError = true;console.log(error);}});});// ...
E também vamos criar nosso componente que será renderizado no lado do servidor. Crie um novo arquivo src/client/HeavyComponent.tsx
com o seguinte conteúdo:
TSXimport React from "react";let status = "pending";let result = null;const asyncFunc = (func: Promise<any>) => {const fetching = func.then((success) => {status = "fulfilled";result = success;}).catch((error) => {status = "rejected";result = error;});if (status === "pending") {throw fetching;} else if (status === "rejected") {throw result;} else if (status === "fulfilled") {return result;}};export default function HeavyComponent() {const data = asyncFunc(getData());async function getData() {await new Promise((resolve) => setTimeout(resolve, 5000));return { username: "itsmicaio" };}return (<div><h3>Demorei mas carreguei!</h3><p>{JSON.stringify(data)}</p></div>);}
Declaramos a variável status="pending"
que será utilizada para informar ao React o estado da promessa. Enquanto estiver pendente o React vai renderizar o fallback da Suspense API
.
A função asyncFunc
é uma função que simula uma chamada assíncrona. Ela recebe uma promise e retorna o resultado dela. Se a promise ainda não foi resolvida, ela lança um erro que é capturado pelo React e renderiza o fallback. Se a promise já foi resolvida, ela retorna o resultado.
Finalmente nossa função que busca os dados. Ela é uma função assíncrona que espera 5 segundos e retorna um objeto com o meu username.
Pra gente conseguir ver a magica acontecendo, vamos utilizar a Suspense API
junto com o HeavyComponent
. Altere o arquivo App.tsx
para o seguinte:
TSXimport React, { lazy, Suspense } from "react";const HeavyComponent = lazy(() => import("./HeavyComponent"));export const App = () => {return (<div id="root"><h1>Test suspense</h1><Suspense fallback={<h3>Carregando...</h3>}><HeavyComponent /></Suspense><Counter /></div>);};
E é só dar um reload na página e vamos conseguir ver nosso componente sendo carregado e posteriormente renderizado na página:

Explicando o HTML final
Se você olhar o HTML final que foi gerado, vai ver que ele está bem diferente do que a gente tinha antes. Agora temos um comentário <!--$?-->
que indica o inicio do nosso componente e um comentário <!--/$-->
que indica o fim do nosso componente. E no meio desses comentários temos um template com um id que é onde o nosso componente deve ser inserido após o carregamento.
HTML<div><h1>Test suspense</h1><!--$?--><template id="B:0"></template><h3>Carregando...</h3><!--/$--><div id="counter"><h1>Counter:<!-- -->0</h1><button>Counter</button></div></div><script src="/bundle.js" async=""></script><div hidden id="S:0"><div><h3>Demorei mas carreguei!</h3><p>{"username ":"itsmicaio "}</p></div></div><script>function $RC(a, b) {a = document.getElementById(a);b = document.getElementById(b);b.parentNode.removeChild(b);if (a) {a = a.previousSibling;var f = a.parentNode, c = a.nextSibling, e = 0;do {if (c && 8 === c.nodeType) {var d = c.data;if ("/$" === d)if (0 === e)break;elsee--;else"$" !== d && "$?" !== d && "$!" !== d || e++}d = c.nextSibling;f.removeChild(c);c = d} while (c);for (; b.firstChild; )f.insertBefore(b.firstChild, c);a.data = "$";a._reactRetry && a._reactRetry()}};$RC("B:0", "S:0")</script>
No final do HTML também temos um script que é responsável por fazer a inserção do nosso componente no HTML. Ele basicamente pega o template com o id B:0
e substitui pelo conteúdo da div com o id S:0
que é justamente o componente carregado no servidor.
Conclusão
Viu só que legal? Deu pra entender de forma mais profunda como funciona as formas de renderização de React e algumas implementações que estão acontecendo por de baixo dos panos em ferramentas como NextJS e Gatsby. É claro que essa é uma implementação bem simples e não recomendo que você utilize em produção, mas é uma ótima forma de entender como as coisas funcionam.
Você pode ver o código completo no github e também pode ver o artigo que fiz explicando o Universo de renderização do React. Fui :p