DEV Community

Emmanuel Galindo
Emmanuel Galindo

Posted on

SSR Boilerplate app - consumes markdowns

Create a SSR app that consumes Markdown files to create a post block.

  • Create dynamically routes for MK files
  • Set webpack SSR
  • Consume data from MK files
  • Get list of MK files

Summary

This is a Boilerplate app for a blog webpage. It uses Server Side Rendering with Webpack 4 and consume Markdown files for populate post pages.


Hello There!

I want to share my repository of a Boilerplate blog application. This application makes the rendering on the server side when the user request the webpage. We all know this as Server-Side-Rendering. It includes two examples: one that returns an static web page (only the html) and another one that includes a JS file that will make more dynamic the webpage. This JS file is bundled and transpiled by Webpack and Babel. Redux is already set, so we can interact with it in case you want to add more functionalities to your application. Finally, I mentioned that is a Blog app, so it shows you how to consume markdown files to be rendered as HTML files.

A personal tip is that use Private Window, and be carefull with the cache from the browser

Overview

Initial steps for both scenarios

  1. The app is running (Node.js + Express)
  2. When Node.js starts, it reads all the MarkDown files and save the content in an object.
  3. The user makes the request

Then, the server process the request. Here are two options for SSR.

Generate static files

  1. Renders the component
  2. If need it, it injects HTML content (markdown content transformed)
  3. Set the full HTML template
  4. Send final HTML file

Generate static files with React+Redux functionality

  1. Create the Redux Store with any initial data
  2. Add the Provider component with the Store created to the app component
  3. Render this new component
  4. Start setting the full HTML template
  5. Save the state from the Store in the window variable
  6. Add in the Script tag, the JS file with our application logic (React app)
  7. Apply the rest of the elements to complete the HTML template
  8. Send final HTML file

How to SSR with Webpack?

Lets see the process.

So, the script command first makes the transpile and bundle process with webpack and then run the node.js server.

"scripts": {
    "start": "webpack --mode production && node index.js",
    "dev": "webpack --mode production && nodemon index.js",
    "build": "webpack --mode production"
  },
Enter fullscreen mode Exit fullscreen mode

Webpack Configuration

The output from Webpack is the JS file that will be call on the tag script from our final HTML file (in case is not a completly static file). This file needs to be serve as static by our server to let the HTML interact with it.

Find the webpack configuration file below:

// webpack.config.js

const path = require('path');

module.exports = {
  entry: {
    client: './src/ssr/reactiveRender/client.js',
  },
  output: {
    path: path.resolve(__dirname, 'assets'),
    filename: '[name].js',
  },
  module: {
    rules: [{ test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader' }],
  },
};
Enter fullscreen mode Exit fullscreen mode

The entry file will be as a normal React render file but with two differences.

import React from 'react';
import { hydrate } from 'react-dom';
import { Provider } from 'react-redux';
import configureStore from '../../redux/configureStore';

import Home from '../../sections/Home';

const state = window.__STATE__;
delete window.__STATE__;
const store = configureStore(state);

hydrate(
  <Provider store={store}>
    {' '}
    <Home />{' '}
  </Provider>,
  document.querySelector('#app')
);
Enter fullscreen mode Exit fullscreen mode

The step with window.__STATE__ is for get the Redux Store generated on the server and pass it to the JS that the client side will use.

Then we use hydrate() instead of render(). Both are mostly the same but hydrate() is used to hydrate elements rendered by ReactDomServer. It ensures that the content is the same on the server and the client.

Node.js Server

As most of the times, lets use express for make things easier. Our index file for this will the normal express set up + importing babel register.

Babel/register is used in test environment. Is for register babel into nodes runtime. The require hook will bind itself to node's require and automatically compile files on the fly.

This is done when we need to run babel without a build step like webpack of rollup, and run babel on the fly.

So, importing in our index.js file will help us to use ES6 syntax because this code that runs the node.js server will not pass through webpack.

We set a path for send back our rendered page. We will use "/" for this.

// mw function to start SSR

// render page with list of posts
//
app.get('/', async (req, res) => {
  // render our HTML file
  const finalHtml = await ssrV2({
    content: listPages.pageTitles,
    Component: Home,
  });

  res.setHeader('Cache-Control', 'assets, max-age=604800');
  res.send(finalHtml);
});
Enter fullscreen mode Exit fullscreen mode

I made another path for the reactive page.

app.get('/reactive_page', (req, res) => {
  // render Home Reactive component
  // include js file which also contains Redux
  const finalHTML = ssrReactive(listPages);
  res.send(finalHTML);
});
Enter fullscreen mode Exit fullscreen mode

SSR module

The key here, is renderToString . It receives our React app and renders it on the server. Now we have HTML content that will be inject in our final HTML. This enables you to render React components to static markup.

Render a React element to its initial HTML. React will return an HTML string. You can use this method to generate HTML on the server and send the markup down on the initial request for faster page loads and to allow search engines to crawl your pages for SEO purposes.
If you call ReactDOM.hydrate() on a node that already has this server-rendered markup, React will preserve it and only attach event handlers, allowing you to have a very performant first-load experience.

// ssr module

import React from 'react';
import { renderToString } from 'react-dom/server';

import remark from 'remark';
import html from 'remark-html';
import template from './template';

// render a component and inserts content to it
// returns an HTML file
const ssr = async ({ content = '', Component, htmlContent = '' } = {}) => {
  // convert the mk content into html syntax
  const contentHtml = await remark().use(html).process(htmlContent);
  let appContent = renderToString(
    <Component content={content} htmlContent={contentHtml} />
  );

  // create a full HTML file with app content
  const fullHtml = template('Post', appContent);

  return fullHtml;
};

export default ssr;
Enter fullscreen mode Exit fullscreen mode

Also we have the option to render our components with Store content.

We create our Redux store and pass it to the Provider Higher Order Component.

Finally, we save the current store status.

import React from 'react';
import { renderToString } from 'react-dom/server';
import { Provider } from 'react-redux';

import configureStore from '../../redux/configureStore';
import template from './template';
import HomeReactive from '../../sections/HomeReactive';

/*
  creates the preloaded state from the Redux store

  renders the content that will be injected to the html file
  in the server side, to be sent to the client
*/
const srr = initialState => {
  // init our redux store
  const store = configureStore(initialState);

  // render our app content and returns a HTML string
  let appContent = renderToString(
    <Provider store={store}>
      <HomeReactive />
    </Provider>
  );

  const preloadedState = store.getState();

  const finalHtml = template('reactive app', preloadedState, appContent);
  return finalHtml;
};

export default srr;
Enter fullscreen mode Exit fullscreen mode

Template module

This module sets the rest of the HTML file content. Then we set the redux store in the window object. You will see in a moment where we will consume it.

This HTML file returned is the one that our node.js server will return as static file.

If you don't need a script attached, you could delete it and send a complete static file.

// html skeleton provider
export default function template(title, initialState = {}, content = '') {
  let scripts = ''; // Dynamically ship scripts based on render type
  if (content) {
    scripts = ` <script>
                   window.__STATE__ = ${JSON.stringify(initialState)}
                </script>
                <script src="assets/client.js"></script>
                `;
  }

  let page = `<!DOCTYPE html>
              <html lang="en">
              <head>
                <meta charset="utf-8">
                <title> ${title} </title>
                <link rel="stylesheet" href="assets/style.css">
              </head>
              <body>
                <div class="content">
                   <div id="app" class="wrap-inner">
                      ${content}
                   </div>
                </div>
                  ${scripts}
              </body>
              `;

  return page;
}
Enter fullscreen mode Exit fullscreen mode

What is a MarkDown file?

Markdown is a lightweight markup language that you can use to add formatting elements to plaintext text documents.

The process for consume these markdown files is next one:

  1. Display the list of posts (each post is a markdown file)
  2. When the user request a post page, the server will render it with the information from the markdown
  3. Send the HTML file

We have two core points here, get the list of posts and consume the markdown file depending on the page requested. Lets check the first one.

Get the list of posts (markdown files)

This will be a module that will read the content directory and will get each name from the files.

export default function getAllPages() {
  const pagesLayers = {};

  // separate directories from files in the content directory (initial)
  const dividedElements = readFilesProcess(process.cwd(), 'content');

  // save the root pages
  pagesLayers['root'] = dividedElements.pages;

  // start reading deeper directories
  dividedElements.directories.forEach(directory => {
    readProcess(directory, path.join(process.cwd(), 'content'));
  });

  // this function is a clousure, because we need to access pagesLayers 
    //to keep tracking the result
  function readProcess(directory, directoryPath) {
    // separate directories from files
    const dividedElLayer = readFilesProcess(directoryPath, directory);

    // save pages from current directory
    pagesLayers[directory] = dividedElLayer.pages;

    // check if there are more directories to check in the current layer
    if (dividedElLayer.directories.length !== 0) {
      // pass the current layer path to could keep going deeper
      dividedElLayer.directories.forEach(directory => {
        readProcess(directory, layerPath);
      });
    }
  }

  return pagesLayers;
}
Enter fullscreen mode Exit fullscreen mode

The variable pagesLayers will save the file names. We can have directories inside the content directory, and they will be save as properties inside pageLayers.

The process consists mainly of reading the files in the directory, separate them into pages (md files) and sub-directories, save the pages in pagesLayers variable and repeat with the sub-directories.

The readFilesProcess function receives a path and a target directory. It will read the files from the target directory. Finally it separates the markdown files from the directories.

// read the elements from target directory
function readFilesProcess(startPath, targetDirectory) {
  // get full path of the directory
  const layerPath = path.join(startPath, targetDirectory);
  // read all files
  const layerFiles = fs.readdirSync(layerPath);

  const accumulator = {
    pageTitles: [],
    fullPages: {},
    directories: [],
  };

  // separate the files in pages and directories
  return layerFiles.reduce((accumulator, currentVal) => {
    // avoid adding next.js files that are not pages
    if (currentVal.endsWith('.md')) {
      // read the file
      const fileContent = fs.readFileSync(layerPath + `/${currentVal}`, 'utf8');
      const newPage = {
        title: currentVal,
        content: fileContent,
        //content: 'content',
      };
      accumulator.fullPages[currentVal] = newPage;
      accumulator.pageTitles.push(currentVal);
    } else {
      accumulator.directories.push(currentVal);
    }
    return accumulator;
  }, accumulator);
}
Enter fullscreen mode Exit fullscreen mode

I am assuming that you don't add any other file than .md but if you do, just change the algorithm.

An example of the structure of the object returned is:

{
  pageTitles: [ 'Minimum_Spanning_Tree.md', 'crud_app.md', 'set_up_prisma.md' ],
  fullPages: {
    'Minimum_Spanning_Tree.md': { title: 'Minimum_Spanning_Tree.md', content: 'content' },
    'crud_app.md': { title: 'crud_app.md', content: 'content' },
    'set_up_prisma.md': { title: 'set_up_prisma.md', content: 'content' }
  }
}
Enter fullscreen mode Exit fullscreen mode

The pageTitles property is for render the menu of all the posts. And the fullPages property has the content for the post pages.

Top comments (0)