I recently started working on a Phoenix/Elixir based project. Well, I’m too rusty for that, I said to myself. After many years of OOP, I was about to delve into the realm of functional programming. But, against common misconceptions, it turned out to be an enjoyable experience, and I literally feel I’m in the safe zone now (pun intended).

I got so excited about this new discovery that I decided to use Phoenix/Elixir for another project of my own. The thing is, I already had the frontend somewhat prototyped in Angular 9, so all I had to do is create a Phoenix/Elixir app that serves an Angular frontend.

So, let’s see how we can make things work.

1. Create the Phoenix project

Open your terminal and navigate to your working directory and create a new Phoenix project. We will also specify it doesn’t add webpack, that will be taken care of by Angular.

$ mix phx.new --no-webpack

[Optional] You can navigate to priv/static/ and remove all files in there. Here is the place our built Angular sources will live.

2. Create the Angular project

In the root folder of your Phoenix project create the Angular project. Let’s call it frontend.

ng new frontend

2.1 Move files

For the sake of simplicity, let’s move angular.json and package.json from the frontend folder to the root folder of our project. The reason we’re doing this is to allow us to run npm and ng (angular-cli) commands straight from the root folder.

By now our project structure should look similar to this:

project file structure

2.2 Remove unused files

Note: If Angular initialized a git repo and installed the default node_modules (it most likely did) you can safely delete them as they will now be available in the root folder. To do that navigate to /frontend and run rm -rf node_modules and rm -rf .git. Also, delete package-lock.json if any - another one will be created in the root folder.

Don’t forget to add /node_modules to your main .gitignore file.

3. Building Angular sources

3.1 Update the source paths

Because we moved some files around, we must to update the angular.json file such that each declared path reflects the new location. In our case we have to add the frontend/ prefix in several places. Check the following gist as a reference.

angular.json

{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "frontend": {
      "projectType": "application",
      "schematics": {},
      "root": "frontend",
      "sourceRoot": "frontend/src",
      "prefix": "app",
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "priv/static/frontend",
            "index": "frontend/src/index.html",
            "main": "frontend/src/main.ts",
            "polyfills": "frontend/src/polyfills.ts",
            "tsConfig": "frontend/tsconfig.app.json",
            "aot": true,
            "assets": [
              "frontend/src/favicon.ico",
              "frontend/src/assets"
            ],
            "styles": [
              "frontend/src/styles.css"
            ],
            "scripts": []
          },
          "configurations": {
            "production": {
              "fileReplacements": [
                {
                  "replace": "frontend/src/environments/environment.ts",
                  "with": "frontend/src/environments/environment.prod.ts"
                }
              ],
              "optimization": true,
              "outputHashing": "all",
              "sourceMap": false,
              "extractCss": true,
              "namedChunks": false,
              "extractLicenses": true,
              "vendorChunk": false,
              "buildOptimizer": true,
              "budgets": [
                {
                  "type": "initial",
                  "maximumWarning": "2mb",
                  "maximumError": "5mb"
                },
                {
                  "type": "anyComponentStyle",
                  "maximumWarning": "6kb",
                  "maximumError": "10kb"
                }
              ]
            }
          }
        },
        "serve": {
          "builder": "@angular-devkit/build-angular:dev-server",
          "options": {
            "browserTarget": "frontend:build"
          },
          "configurations": {
            "production": {
              "browserTarget": "frontend:build:production"
            }
          }
        },
        "extract-i18n": {
          "builder": "@angular-devkit/build-angular:extract-i18n",
          "options": {
            "browserTarget": "frontend:build"
          }
        },
        "test": {
          "builder": "@angular-devkit/build-angular:karma",
          "options": {
            "main": "src/test.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.spec.json",
            "karmaConfig": "karma.conf.js",
            "assets": [
              "src/favicon.ico",
              "src/assets"
            ],
            "styles": [
              "src/styles.css"
            ],
            "scripts": []
          }
        },
        "lint": {
          "builder": "@angular-devkit/build-angular:tslint",
          "options": {
            "tsConfig": [
              "tsconfig.app.json",
              "tsconfig.spec.json",
              "e2e/tsconfig.json"
            ],
            "exclude": [
              "**/node_modules/**"
            ]
          }
        },
        "e2e": {
          "builder": "@angular-devkit/build-angular:protractor",
          "options": {
            "protractorConfig": "e2e/protractor.conf.js",
            "devServerTarget": "frontend:serve"
          },
          "configurations": {
            "production": {
              "devServerTarget": "frontend:serve:production"
            }
          }
        }
      }
    }
  },
  "defaultProject": "frontend",
  "cli": {
    "analytics": false
  }
}

3.2 Set the frontend build output path

When Angular builds the project it creates a bunch of files that will end up in your users’ browsers. However, Phoenix has no idea whatsoever about those files unless we tell it about them.

Notice that angular.json file has a build option called outputPath, which is where the built files will be delivered. We will tell Phoenix to serve the files statically and because it already has a designated folder for static content, we will use it as Angular’s output path:

...
"outputPath": "priv/static/frontend",
...

3.3 Test build output

We are ready to do some tests. In your root folder run npm install to install missing dependencies, then ng build.

Once the build is done you should see a bunch of colorful lines in your terminal, representing the built files. But most importantly, open your editor and check the priv/static/frontend folder. You should be able to see all the files generated in the previous step by Angular.

4. Tying things together

4.1 Import sources

Now that we have all the frontend files, we need to tell Phoenix to serve them.

Angular is a single page application, which is why there is an index.html file that imports all the other components. With Phoenix, we can ignore that file and use the app.html.eex template file instead. You can find it in the lib/phoenix_angular_web/templates/layout/ folder. Phoenix comes with a powerful server-side template engine, but we will only use it to load the Angular files.

Open the file and replace the entire <body> with the the following lines:

<body>
    <app-root></app-root>
    <!-- Files generated with `ng build` -->
    <script  src="<%= Routes.static_path(@conn, "/frontend/runtime-es2015.js")  %>" type="module"></script>
    <script  src="<%= Routes.static_path(@conn, "/frontend/runtime-es5.js")  %>"  nomodule  defer></script>
    <script  src="<%= Routes.static_path(@conn, "/frontend/polyfills-es5.js")  %>"  nomodule  defer></script>
    <script  src="<%= Routes.static_path(@conn, "/frontend/polyfills-es2015.js")  %>"  type="module"></script>
    <script  src="<%= Routes.static_path(@conn, "/frontend/styles-es2015.js")  %>"  type="module"></script>
    <script  src="<%= Routes.static_path(@conn, "/frontend/styles-es5.js")  %>"  nomodule  defer></script>
    <script  src="<%= Routes.static_path(@conn, "/frontend/vendor-es2015.js")  %>"  type="module"></script>
    <script  src="<%= Routes.static_path(@conn, "/frontend/vendor-es5.js")  %>"  nomodule  defer></script>
    <script  src="<%= Routes.static_path(@conn, "/frontend/main-es2015.js")  %>"  type="module"></script>
    <script  src="<%= Routes.static_path(@conn, "/frontend/main-es5.js")  %>"  nomodule  defer></script>

    <!-- Files generated with `ng build --watch` -->
    <script  src="<%= Routes.static_path(@conn, "/frontend/polyfills.js")  %>"  type="module"></script>
    <script  src="<%= Routes.static_path(@conn, "/frontend/styles.js")  %>"  type="module"></script>
    <script  src="<%= Routes.static_path(@conn, "/frontend/runtime.js")  %>"  type="module"></script>
    <script  src="<%= Routes.static_path(@conn, "/frontend/vendor.js")  %>"  type="module"></script>
    <script  src="<%= Routes.static_path(@conn, "/frontend/main.js")  %>"  type="module"></script>
</body>

4.2 Add the base path

Angular also requires a <base href="/"> tag. Add it as a child element to <head>.

<head>
    <!-- other elements -->
    <base  href="/">
</head>

4.3 Allow static imports from frontend folder

Open the lib/pheonix_angular_web/endpoint.ex and locate the configuration of the static imports plug. Tip: it starts with plug Plug.Static. add frontend to the only list. It should look similar to this:

plug Plug.Static,
    at:  "/",
    from:  :phoenix_angular,
    gzip:  false,
    only:  ~w(css fonts images js favicon.ico robots.txt frontend) # <- here

You can also remove the items that are not used anymore (css fonts images js ...)

5. Run and Watch

5.1 Run the server

We’re all set. Run mix phx.server and open the browser. If all went well, you should see the app.component rendered. You may also want to double-check that the frontend files are generated, if not run ng build before starting the server.

5.1 Watch for changes

Right now, it is too much work to rebuild and restart the server each time we make changes to our code. Thankfully, we can instruct Phoenix to watch over our changed files and rebuild the sources automatically. To do that, open the config/dev.exs file and add the following watcher to the endpoint configuration:

config :phoenix_angular, PhoenixAngularWeb.Endpoint,
    http: [port:  4000],
    debug_errors:  true,
    code_reloader:  true,
    check_origin:  false,
    watchers: [
        ng: ["build", "--watch"] # <- this one
    ]

Earlier we added two sets of files in the body element. The first set is created when building the files statically (and will end up in production), whereas the second is generated when building and watching files. This seems like a bug, but the second set is used for Angular differential loading, which is building files a lot faster to save development time.

Troubleshooting

Normally, you will figure out on your what to do based on the messages if anything goes haywire. But here are some things I encountered along the way.

  1. ng build terminates with errors.
    • Make sure you don’t have any errors in your files (.ts, .html).
    • Some package is breaking the build. For me, it was ng-cli-pug-loader that wasn’t translating .pug files to .html and thus resulting in verbose errors.
  2. Failed to load resource: the server responded with a status of 404 (Not Found)
    • With the current setup, you will get some of these errors depending on which set of files is generated at build time (see step 4).
    • However, these imports are taken from priv/static/frontent/index.html. Make sure all imports in that file are also imported in your app.html.eex file.

If you’d like to skip the steps above, please check out this ready-made template: https://github.com/ccheptea/phoenix-angular-template