Step 27: Building an SPA

5.0 version
Maintained

Building an SPA

Most of the comments will be submitted during the conference where some people do not bring a laptop. But they probably have a smartphone. What about creating a mobile app to quickly check the conference comments?

One way to create such a mobile application is to build a Javascript Single Page Application (SPA). An SPA runs locally, can use local storage, can call a remote HTTP API, and can leverage service workers to create an almost native experience.

Creating the Application

To create the mobile application, we are going to use Preact and Symfony Encore. Preact is a small and efficient foundation well-suited for the Guestbook SPA.

To make both the website and the SPA consistent, we are going to reuse the Sass stylesheets of the website for the mobile application.

Create the SPA application under the spa directory and copy the website stylesheets:

1
2
3
$ mkdir -p spa/src spa/public spa/assets/css
$ cp assets/css/*.scss spa/assets/css/
$ cd spa

Note

We have created a public directory as we will mainly interact with the SPA via a browser. We could have named it build if we only wanted to build a mobile application.

Initialize the package.json file (equivalent of the composer.json file for JavaScript):

1
$ yarn init -y

Now, add some required dependencies:

1
$ yarn add @symfony/webpack-encore @babel/core @babel/preset-env babel-preset-preact preact html-webpack-plugin bootstrap

For good measure, add a .gitignore file:

.gitignore
1
2
3
4
5
/node_modules
/public
/yarn-error.log
# used later by Cordova
/app

The last configuration step is to create the Webpack Encore configuration:

webpack.config.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const Encore = require('@symfony/webpack-encore');
const HtmlWebpackPlugin = require('html-webpack-plugin');

Encore
    .setOutputPath('public/')
    .setPublicPath('/')
    .cleanupOutputBeforeBuild()
    .addEntry('app', './src/app.js')
    .enablePreactPreset()
    .enableSingleRuntimeChunk()
    .addPlugin(new HtmlWebpackPlugin({ template: 'src/index.ejs', alwaysWriteToDisk: true }))
;

module.exports = Encore.getWebpackConfig();

Creating the SPA Main Template

Time to create the initial template in which Preact will render the application:

src/index.ejs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="msapplication-tap-highlight" content="no" />
    <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width" />

    <title>Conference Guestbook application</title>
</head>
<body>
    <div id="app"></div>
</body>
</html>

The <div> tag is where the application will be rendered by JavaScript. Here is the first version of the code that renders the “Hello World” view:

src/app.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import {h, render} from 'preact';

function App() {
    return (
        <div>
            Hello world!
        </div>
    )
}

render(<App />, document.getElementById('app'));

The last line registers the App() function on the #app element of the HTML page.

Everything is now ready!

Running an SPA in the Browser

As this application is independent of the main website, we need to run another web server:

1
$ symfony server:stop
1
$ symfony server:start -d --passthru=index.html

The --passthru flag tells the web server to pass all HTTP requests to the public/index.html file (public/ is the web server default web root directory). This page is managed by the Preact application and it gets the page to render via the “browser” history.

To compile the CSS and the JavaScript files, run yarn:

1
$ yarn encore dev

Open the SPA in a browser:

1
$ symfony open:local

And look at our hello world SPA:

Adding a Router to handle States

The SPA is currently not able to handle different pages. To implement several pages, we need a router, like for Symfony. We are going to use preact-router. It takes a URL as an input and matches a Preact component to display.

Install preact-router:

1
$ yarn add preact-router

Create a page for the homepage (a Preact component):

src/pages/home.js
1
2
3
4
5
6
7
import {h} from 'preact';

export default function Home() {
    return (
        <div>Home</div>
    );
};

And another for the conference page:

src/pages/conference.js
1
2
3
4
5
6
7
import {h} from 'preact';

export default function Conference() {
    return (
        <div>Conference</div>
    );
};

Replace the “Hello World” div with the Router component:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
--- a/src/app.js
+++ b/src/app.js
@@ -1,9 +1,22 @@
 import {h, render} from 'preact';
+import {Router, Link} from 'preact-router';
+
+import Home from './pages/home';
+import Conference from './pages/conference';

 function App() {
     return (
         <div>
-            Hello world!
+            <header>
+                <Link href="/">Home</Link>
+                <br />
+                <Link href="/conference/amsterdam2019">Amsterdam 2019</Link>
+            </header>
+
+            <Router>
+                <Home path="/" />
+                <Conference path="/conference/:slug" />
+            </Router>
         </div>
     )
 }

Rebuild the application:

1
$ yarn encore dev

If you refresh the application in the browser, you can now click on the “Home” and conference links. Note that the browser URL and the back/forward buttons of your browser work as you would expect it.

Styling the SPA

As for the website, let’s add the Sass loader:

1
$ yarn add node-sass "[email protected]^7.0"

Enable the Sass loader in Webpack and add a reference to the stylesheet:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
--- a/src/app.js
+++ b/src/app.js
@@ -1,3 +1,5 @@
+import '../assets/css/app.scss';
+
 import {h, render} from 'preact';
 import {Router, Link} from 'preact-router';

--- a/webpack.config.js
+++ b/webpack.config.js
@@ -7,6 +7,7 @@ Encore
     .cleanupOutputBeforeBuild()
     .addEntry('app', './src/app.js')
     .enablePreactPreset()
+    .enableSassLoader()
     .enableSingleRuntimeChunk()
     .addPlugin(new HtmlWebpackPlugin({ template: 'src/index.ejs', alwaysWriteToDisk: true }))
 ;

We can now update the application to use the stylesheets:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
--- a/src/app.js
+++ b/src/app.js
@@ -9,10 +9,20 @@ import Conference from './pages/conference';
 function App() {
     return (
         <div>
-            <header>
-                <Link href="/">Home</Link>
-                <br />
-                <Link href="/conference/amsterdam2019">Amsterdam 2019</Link>
+            <header className="header">
+                <nav className="navbar navbar-light bg-light">
+                    <div className="container">
+                        <Link className="navbar-brand mr-4 pr-2" href="/">
+                            &#128217; Guestbook
+                        </Link>
+                    </div>
+                </nav>
+
+                <nav className="bg-light border-bottom text-center">
+                    <Link className="nav-conference" href="/conference/amsterdam2019">
+                        Amsterdam 2019
+                    </Link>
+                </nav>
             </header>

             <Router>

Rebuild the application once more:

1
$ yarn encore dev

You can now enjoy a fully styled SPA:

Fetching Data from the API

The Preact application structure is now finished: Preact Router handles the page states - including the conference slug placeholder - and the main application stylesheet is used to style the SPA.

To make the SPA dynamic, we need to fetch the data from the API via HTTP calls.

Configure Webpack to expose the API endpoint environment variable:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,3 +1,4 @@
+const webpack = require('webpack');
 const Encore = require('@symfony/webpack-encore');
 const HtmlWebpackPlugin = require('html-webpack-plugin');

@@ -10,6 +11,9 @@ Encore
     .enableSassLoader()
     .enableSingleRuntimeChunk()
     .addPlugin(new HtmlWebpackPlugin({ template: 'src/index.ejs', alwaysWriteToDisk: true }))
+    .addPlugin(new webpack.DefinePlugin({
+        'ENV_API_ENDPOINT': JSON.stringify(process.env.API_ENDPOINT),
+    }))
 ;

 module.exports = Encore.getWebpackConfig();

The API_ENDPOINT environment variable should point to the web server of the website where we have the API endpoint under /api. We will configure it properly when we will run yarn encore soon.

Create an api.js file that abstracts data retrieval from the API:

src/api/api.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function fetchCollection(path) {
    return fetch(ENV_API_ENDPOINT + path).then(resp => resp.json()).then(json => json['hydra:member']);
}

export function findConferences() {
    return fetchCollection('api/conferences');
}

export function findComments(conference) {
    return fetchCollection('api/comments?conference='+conference.id);
}

You can now adapt the header and home components:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
--- a/src/app.js
+++ b/src/app.js
@@ -2,11 +2,23 @@ import '../assets/css/app.scss';

 import {h, render} from 'preact';
 import {Router, Link} from 'preact-router';
+import {useState, useEffect} from 'preact/hooks';

+import {findConferences} from './api/api';
 import Home from './pages/home';
 import Conference from './pages/conference';

 function App() {
+    const [conferences, setConferences] = useState(null);
+
+    useEffect(() => {
+        findConferences().then((conferences) => setConferences(conferences));
+    }, []);
+
+    if (conferences === null) {
+        return <div className="text-center pt-5">Loading...</div>;
+    }
+
     return (
         <div>
             <header className="header">
@@ -19,15 +31,17 @@ function App() {
                 </nav>

                 <nav className="bg-light border-bottom text-center">
-                    <Link className="nav-conference" href="/conference/amsterdam2019">
-                        Amsterdam 2019
-                    </Link>
+                    {conferences.map((conference) => (
+                        <Link className="nav-conference" href={'/conference/'+conference.slug}>
+                            {conference.city} {conference.year}
+                        </Link>
+                    ))}
                 </nav>
             </header>

             <Router>
-                <Home path="/" />
-                <Conference path="/conference/:slug" />
+                <Home path="/" conferences={conferences} />
+                <Conference path="/conference/:slug" conferences={conferences} />
             </Router>
         </div>
     )
--- a/src/pages/home.js
+++ b/src/pages/home.js
@@ -1,7 +1,28 @@
 import {h} from 'preact';
+import {Link} from 'preact-router';
+
+export default function Home({conferences}) {
+    if (!conferences) {
+        return <div className="p-3 text-center">No conferences yet</div>;
+    }

-export default function Home() {
     return (
-        <div>Home</div>
+        <div className="p-3">
+            {conferences.map((conference)=> (
+                <div className="card border shadow-sm lift mb-3">
+                    <div className="card-body">
+                        <div className="card-title">
+                            <h4 className="font-weight-light">
+                                {conference.city} {conference.year}
+                            </h4>
+                        </div>
+
+                        <Link className="btn btn-sm btn-blue stretched-link" href={'/conference/'+conference.slug}>
+                            View
+                        </Link>
+                    </div>
+                </div>
+            ))}
+        </div>
     );
-};
+}

Finally, Preact Router is passing the “slug” placeholder to the Conference component as a property. Use it to display the proper conference and its comments, again using the API; and adapt the rendering to use the API data:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
--- a/src/pages/conference.js
+++ b/src/pages/conference.js
@@ -1,7 +1,48 @@
 import {h} from 'preact';
+import {findComments} from '../api/api';
+import {useState, useEffect} from 'preact/hooks';
+
+function Comment({comments}) {
+    if (comments !== null && comments.length === 0) {
+        return <div className="text-center pt-4">No comments yet</div>;
+    }
+
+    if (!comments) {
+        return <div className="text-center pt-4">Loading...</div>;
+    }
+
+    return (
+        <div className="pt-4">
+            {comments.map(comment => (
+                <div className="shadow border rounded-lg p-3 mb-4">
+                    <div className="comment-img mr-3">
+                        {!comment.photoFilename ? '' : (
+                            <a href={ENV_API_ENDPOINT+'uploads/photos/'+comment.photoFilename} target="_blank">
+                                <img src={ENV_API_ENDPOINT+'uploads/photos/'+comment.photoFilename} />
+                            </a>
+                        )}
+                    </div>
+
+                    <h5 className="font-weight-light mt-3 mb-0">{comment.author}</h5>
+                    <div className="comment-text">{comment.text}</div>
+                </div>
+            ))}
+        </div>
+    );
+}
+
+export default function Conference({conferences, slug}) {
+    const conference = conferences.find(conference => conference.slug === slug);
+    const [comments, setComments] = useState(null);
+
+    useEffect(() => {
+        findComments(conference).then(comments => setComments(comments));
+    }, [slug]);

-export default function Conference() {
     return (
-        <div>Conference</div>
+        <div className="p-3">
+            <h4>{conference.city} {conference.year}</h4>
+            <Comment comments={comments} />
+        </div>
     );
-};
+}

The SPA now needs to know the URL to our API, via the API_ENDPOINT environment variable. Set it to the API web server URL (running in the .. directory):

1
$ API_ENDPOINT=`symfony var:export SYMFONY_DEFAULT_ROUTE_URL --dir=..` yarn encore dev

You could also run in the background now:

1
$ API_ENDPOINT=`symfony var:export SYMFONY_DEFAULT_ROUTE_URL --dir=..` symfony run -d --watch=webpack.config.js yarn encore dev --watch

And the application in the browser should now work properly:

Wow! We now have a fully-functional, SPA with router and real data. We could organize the Preact app further if we want, but it is already working great.

Deploying the SPA in Production

SymfonyCloud allows to deploy multiple applications per project. Adding another application can be done by creating a .symfony.cloud.yaml file in any sub-directory. Create one under spa/ named spa:

.symfony.cloud.yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
name: spa

type: php:7.3
size: S
disk: 256

build:
    flavor: none

dependencies:
    nodejs:
        yarn: "*"

web:
    commands:
        start: sleep
    locations:
        "/":
            root: "public"
            index:
                - "index.html"
            scripts: false
            expires: 10m

hooks:
    build: |
        set -x -e

        curl -s https://get.symfony.com/cloud/configurator | (>&2 bash)
        yarn-install
        npm rebuild node-sass
        yarn encore prod

Edit the .symfony/routes.yaml file to route the spa. subdomain to the spa application stored in the project root directory:

1
$ cd ../
patch_file
1
2
3
4
5
6
7
8
--- a/.symfony/routes.yaml
+++ b/.symfony/routes.yaml
@@ -1,2 +1,5 @@
+"https://spa.{all}/": { type: upstream, upstream: "spa:http" }
+"http://spa.{all}/": { type: redirect, to: "https://spa.{all}/" }
+
 "https://{all}/": { type: upstream, upstream: "varnish:http", cache: { enabled: false } }
 "http://{all}/": { type: redirect, to: "https://{all}/" }

Configuring CORS for the SPA

If you deploy the code now, it won’t work as a browser would block the API request. We need to explicitly allow the SPA to access the API. Get the current domain name attached to your application:

1
$ symfony env:urls --first

Define the CORS_ALLOW_ORIGIN environment variable accordingly:

1
$ symfony var:set "CORS_ALLOW_ORIGIN=^`symfony env:urls --first | sed 's#/$##' | sed 's#https://#https://spa.#'`$"

If your domain is https://master-5szvwec-hzhac461b3a6o.eu.s5y.io/, the sed calls will convert it to https://spa.master-5szvwec-hzhac461b3a6o.eu.s5y.io.

We also need to set the API_ENDPOINT environment variable:

1
$ symfony var:set API_ENDPOINT=`symfony env:urls --first`

Commit and deploy:

1
2
3
$ git add .
$ git commit -a -m'Add the SPA application'
$ symfony deploy

Access the SPA in a browser by specifying the application as a flag:

1
$ symfony open:remote --app=spa

Using Cordova to build a Smartphone Application

Apache Cordova is a tool that builds cross-platform smartphone applications. And good news, it can use the SPA that we have just created.

Let’s install it:

1
2
$ cd spa
$ yarn global add cordova

Note

You also need to install the Android SDK. This section only mentions Android, but Cordova works with all mobile platforms, including iOS.

Create the application directory structure:

1
$ cordova create app

And generate the Android application:

1
2
3
$ cd app
$ cordova platform add android
$ cd ..

That’s all you need. You can now build the production files and move them to Cordova:

1
2
3
4
$ API_ENDPOINT=`symfony var:export SYMFONY_DEFAULT_ROUTE_URL --dir=..` yarn encore production
$ rm -rf app/www
$ mkdir -p app/www
$ cp -R public/ app/www

Run the application on a smartphone or an emulator:

1
$ cordova run android

  • « Previous Step 26: Exposing an API with API Platform
  • Next » Step 28: Localizing an Application

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.