SSR: rendering a ReactJS application in the backend using PHP





Our task was to implement a website builder. On the front, everything is run by a React application that, based on user actions, generates JSON with information on how to build HTML, and stores it in the PHP backend. Instead of duplicating the HTML assembly logic on the backend, we decided to reuse the JS code. Obviously, this will simplify maintenance, since the code will only change in one place by one person. Here Server Side Rendering comes to the rescue along with the V8 engine and PHP-extension V8JS.



In this article, we'll cover how we used V8Js for our specific task, but the use cases aren't limited to just that. The most obvious is the ability to use Server Side Rendering to fulfill your SEO needs.



Setting up



We use Symfony and Docker, so the first step is to initialize an empty project and set up the environment. Let's note the main points:



  1. The V8Js-extension must be installed in the Dockerfile:



    ...
    RUN apt-get install -y software-properties-common
    RUN add-apt-repository ppa:stesie/libv8 && apt-get update
    RUN apt-get install -y libv8-7.5 libv8-7.5-dev g++ expect
    RUN git clone https://github.com/phpv8/v8js.git /usr/local/src/v8js && \
       cd /usr/local/src/v8js && phpize && ./configure --with-v8js=/opt/libv8-7.5 && \
       export NO_INTERACTION=1 && make all -j4 && make test install
    
    RUN echo extension=v8js.so > /etc/php/7.2/fpm/conf.d/99-v8js.ini
    RUN echo extension=v8js.so > /etc/php/7.2/cli/conf.d/99-v8js.ini
    ...
    


  2. Installing React and ReactDOM in the easiest way

  3. Add index route and default controller:



    <?php
    declare(strict_types=1);
    
    namespace App\Controller;
    
    use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\Routing\Annotation\Route;
    
    final class DefaultController extends AbstractController
    {
       /**
        * @Route(path="/")
        */
       public function index(): Response
       {
           return $this->render('index.html.twig');
       }
    }
    


  4. Add the index.html.twig template with React included



    <html>
    <body>
        <div id="app"></div>
        <script src="{{ asset('assets/react.js') }}"></script>
        <script src="{{ asset('assets/react-dom.js') }}"></script>
        <script src="{{ asset('assets/babel.js') }}"></script>
        <script type="text/babel" src="{{ asset('assets/front.jsx') }}"></script>
    </body>
    </html>
    


Using



To demonstrate V8, let's create a simple H1 and P render script with assets / front.jsx text:



'use strict';

class DataItem extends React.Component {
   constructor(props) {
       super(props);

       this.state = {
           checked: props.name,
           names: ['h1', 'p']
       };

       this.change = this.change.bind(this);
       this.changeText = this.changeText.bind(this);
   }

   render() {
       return (
           <li>
               <select value={this.state.checked} onChange={this.change} >
                   {
                       this.state.names.map((name, k) => {
                           return (
                               <option key={k} value={name}>{name}</option>
                           );
                       })
                   }
               </select>
               <input type='text' value={this.state.value} onChange={this.changeText} />
           </li>
       );
   }

   change(e) {
       let newval = e.target.value;
       if (this.props.onChange) {
           this.props.onChange(this.props.number, newval)
       }
       this.setState({checked: newval});
   }

   changeText(e) {
       let newval = e.target.value;
       if (this.props.onChangeText) {
           this.props.onChangeText(this.props.number, newval)
       }
   }
}

class DataList extends React.Component {
   constructor(props) {
       super(props);
       this.state = {
           message: null,
           items: []
       };

       this.add = this.add.bind(this);
       this.save = this.save.bind(this);
       this.updateItem = this.updateItem.bind(this);
       this.updateItemText = this.updateItemText.bind(this);
   }

   render() {
       return (
           <div>
               {this.state.message ? this.state.message : ''}
               <ul>
                   {
                       this.state.items.map((item, i) => {
                           return (
                               <DataItem
                                   key={i}
                                   number={i}
                                   value={item.name}
                                   onChange={this.updateItem}
                                   onChangeText={this.updateItemText}
                               />
                           );
                       })
                   }
               </ul>
               <button onClick={this.add}></button>
               <button onClick={this.save}></button>
           </div>
       );
   }

   add() {
       let items = this.state.items;
       items.push({
           name: 'h1',
           value: ''
       });

       this.setState({message: null, items: items});
   }

   save() {
       fetch(
           '/save',
           {
               method: 'POST',
               headers: {
                   'Content-Type': 'application/json;charset=utf-8'
               },
               body: JSON.stringify({
                   items: this.state.items
               })
           }
       ).then(r => r.json()).then(r => {
           this.setState({
               message: r.id,
               items: []
           })
       });
   }

   updateItem(k, v) {
       let items = this.state.items;
       items[k].name = v;

       this.setState({items: items});
   }

   updateItemText(k, v) {
       let items = this.state.items;
       items[k].value = v;

       this.setState({items: items});
   }
}

const domContainer = document.querySelector('#app');
ReactDOM.render(React.createElement(DataList), domContainer);


Go to localhost: 8088 (8088 is specified in docker-compose.yml as nginx port):







  1. DB

    create table data(
       id serial not null primary key,
       data json not null
    );


  2. Route

    /**
    * @Route(path="/save")
    */
    public function save(Request $request): Response
    {
       $em = $this->getDoctrine()->getManager();
    
       $data = (new Data())->setData(json_decode($request->getContent(), true));
       $em->persist($data);
       $em->flush();
    
       return new JsonResponse(['id' => $data->getId()]);
    }




We press the save button, when you click on our route, JSON is sent:



{
  "items":[
     {
        "name":"h1",
        "value":" "
     },
     {
        "name":"p",
        "value":" "
     },
     {
        "name":"h1",
        "value":"  "
     },
     {
        "name":"p",
        "value":"   "
     }
  ]
}


In response, the identifier of the record in the database is returned:



/**
* @Route(path="/save")
*/
public function save(Request $request): Response
{
   $em = $this->getDoctrine()->getManager();

   $data = (new Data())->setData(json_decode($request->getContent(), true));
   $em->persist($data);
   $em->flush();

   return new JsonResponse(['id' => $data->getId()]);
}


Now that you have some test data, you can try the V8 in action. To do this, you will need to sketch a React script that will form components from the passed Dom props. Let's put it next to other assets and call it ssr.js:



'use strict';

class Render extends React.Component {
   constructor(props) {
       super(props);
   }

   render() {
       return React.createElement(
           'div',
           {},
           this.props.items.map((item, k) => {
               return React.createElement(item.name, {}, item.value);
           })
       );
   }
}


In order to form a string from the generated DOM tree, we will use the ReactDomServer component (https://unpkg.com/browse/react-dom@16.13.0/umd/react-dom-server.browser.production.min.js). Let's write a route with ready HTML:




/**
* @Route(path="/publish/{id}")
*/
public function renderPage(int $id): Response
{
   $data = $this->getDoctrine()->getManager()->find(Data::class, $id);

   if (!$data) {
       return new Response('<h1>Page not found</h1>', Response::HTTP_NOT_FOUND);
   }

   $engine = new \V8Js();

   ob_start();
   $engine->executeString($this->createJsString($data));

   return new Response(ob_get_clean());
}

private function createJsString(Data $data): string
{
   $props = json_encode($data->getData());
   $bundle = $this->getRenderString();

   return <<<JS
var global = global || this, self = self || this, window = window || this;
$bundle;
print(ReactDOMServer.renderToString(React.createElement(Render, $props)));
JS;
}

private function getRenderString(): string
{
   return
       sprintf(
           "%s\n%s\n%s\n%s",
           file_get_contents($this->reactPath, true),
           file_get_contents($this->domPath, true),
           file_get_contents($this->domServerPath, true),
           file_get_contents($this->ssrPath, true)
       );
}


Here:



  1. reactPath - path to react.js
  2. domPath - path to react-dom.js
  3. domServerPath - path to react-dom-server.js
  4. ssrPath - the path to our ssr.js script


Follow the link / publish / 3:







As you can see, everything was rendered exactly as we need it.



Conclusion



In conclusion, I would like to say that Server Side Rendering is not so difficult and can be very useful. The only thing worth adding here is that rendering can take quite a long time, and it is better to add a queue here - RabbitMQ or Gearman.



PPS Source code can be viewed here https://github.com/damir-in/ssr-php-symfony



Authors

damir_in zinvapel



All Articles