Elixir: Hosting a SPA using Plug.Router, Plug.Static, and Cowboy

by Drannex42 (t. Macleod Sawyer) :: Mar 29, 2022

I am developing a new type of Nerves-based software using Elixir, and didn't want the overhead or all of the features (and design paradigms) of Phoenix. Instead I decided to use Plug.Cowboy and Plug.Static to handle a web server that hosts a Svelte SPA (that is built from Vite) and reads from an API that communicates with our firmware directly.

Common problems with hosting an SPA using Plug.Static (and nearly every other static file asset server):

  1. You can host static files fine, but for routing (which an SPA normally handles for you) you could not arbitrarily go to a /location/like/this without first passing through /index.html. This makes hyperlinking impossible.
  2. We need to also host a set of (rest) API's for our application to communicate with.

There were a few pre-existing solutions in Elixir, including using a six year old package that used regex, some outdated calls, required loading yet another dependency, and not handling matching root paths without a slash.

Some requirements were needed:

  1. No additional dependencies, I prefer to keep minimal dependencies in my projects, lower security attack surfaces, easier to manage, faster loading times, smaller builds.
  2. Understndable and modifiable, everything needs to be able to be understandable on first glance
  3. No project specific API's as I want this to be used in more than just the current project.
  4. Make it simple and clean.

How do we solve this?

We use our build manager to output our production/dev builds to a /priv/app folder, define Plug.Static to read from thosebuilt files and directories, and then created a match _ do statement that loads a file from the private directory (index.html).

Anything that would normally come up as a 404, instead loads the index.html, the entry point to your SPA (which should handle your routing for you).

I've used this method before in the D Language with vibe.d [Warning: That code is not good.].

How to integrate

  1. Have your SPA build output to a /priv/app folder (or wherever else you choose)
  2. Define where your Plug.Static is to read from
  3. Define your API endpoints, if you want.
  4. When it can't find a match (404: Not Found), inject the index.html file and let the SPA handle the routing.

Example

defmodule YourApp do
use Plug.Router
use Plug.ErrorHandler

plug Plug.Static,
at: "/",
from: {:your_app, "priv/app"},
# Only allow these files and directories (define your SPA output folders)
# that way you won't receive `Plug.Conn.AlreadySentError` when using the /api endpoints.
only: ~w(index.html assets favicon.ico)

# API Endpoints
# Any other routes you need that aren't part of the SPA view
plug(:match)
plug(:dispatch)

get "/api/v1" do
send_resp(conn, 200, "Feel free to use API endpoints!")
end

get "/api/v1/hello" do
send_resp(conn, 200, "Hello World!")
end

# Handle all 404s by injecting the index.html file at that route,
# and let the SPA handle it.
match _ do
conn = %{conn | resp_headers: [{"content-type", "text/html"}]}
send_file(conn, 200, Path.join(:code.priv_dir(:your_app), "app/index.html"))
end

defp handle_errors(conn, %{kind: _kind, reason: _reason, stack: _stack}) do
send_resp(conn, conn.status, "Something went wrong")
end
end

That's it! A simple and clean soluton for something that has plagued issues across the Plug repositories for years. The web is increasingly heading towards using single page applications (for better or worse) and I hope this helps someone else down the line as it has for me.