If you have generated your Phoenix app thinking you would never have to render HTML but realized later on you need to, you have landed at the right place.
This is not something easy to do since it requires carefully adding the right components at the right place.

Please note this is for Phoenix v1.6+ running with eslint.

TL-DR for anyone already comfortable with the framework:

  • add the dependencies phoenix_html, phoenix_live_view, phoenix_live_reload and esbuild
  • create the template folder
  • create the default HTML app.html and root.html templates
  • create your controller and your template
  • in your router, define the browser pipeline and your routes
  • create your views (the glue between template & controller)
  • import the standard helpers
  • setup the Live Reload
  • Create your JS/CSS builder

Dependencies

First thing first, let's add the following dependencies:

{:phoenix_html, "~> 3.0"},
{:phoenix_live_view, "0.16.3"},
{:phoenix_live_reload, "~> 1.3"},
{:esbuild, "0.2.1", runtime: Mix.env() == :dev},

Install the dependencies:

$ mix deps.get

Index page

Let's build a sample page and render an index page:

HTML

  1. In your app_web folder, create a templates folder.
  2. In templates, create a layout folder
  3. Finally in layout, create a root.html.eex and app.html.eex file:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <%= csrf_meta_tag() %>
    <%= live_title_tag assigns[:page_title] || "KS", suffix: " · Phoenix Framework" %>
    <link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/assets/app.css") %>"/>
    <script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/assets/app.js") %>"></script>
  </head>
  <body>
    <header>
      <h1>Your app</h1>
    </header>
    <%= @inner_content %>
  </body>
</html>
app_web/templates/layout/root.html.eex
<div class="container">
    <%= @inner_content %>
</div>

Controllers

Now we have our HTML, let's write the handler:

In controllers, add your new controller (you can name the modules as you want but make sure to stick to your naming in the following steps).

I like to have an abstract base_controller that all my other controllers can inherit from:

defmodule AppWeb.BaseController do
  defmacro __using__(_) do
    quote do
      use AppWeb, :controller
      import Phoenix.Controller
    end
  end
end

And then my real controller:

defmodule AppWeb.IndexController do
  use AppWeb.BaseController
  
  def index(conn, _params) do
    render(conn, :index, %{})
  end
end

Router

You will need a new pipeline that process queries coming from a browser:

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :put_root_layout, {AppWeb.LayoutView, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

You can now add a new endpoint to test your view:

  scope "/", AppWeb do
    pipe_through :browser
    get("/", IndexController, :index)
  end

And... it doesn't work! Indeed, Phoenix needs some glue between the HTML template and the controller. And this glue is called 'view'.

Views

A view is just a module that inherits the view behaviour of phoenix_html. By default, it provides a render function that can be used in your controller to render the HTML.

Something important to note about views is that Phoenix will use the module of your controller to find the path of the view to use. If your controller is named AppWeb.IndexController the view needs to be AppWeb.IndexView directly in views directory. However, if you are like me and like to organize differently, if your controller is AppWeb.App.IndexController phoenix will be looking for a module AppWeb.App.IndexView in views/app/index.ex

Let's write a view for our index controller:

defmodule AppWeb.IndexView do
  use AppWeb, :view
end

Now you can try again and... it's still not working! Indeed, the layout HTML file we previously created also needs its own view!

defmodule AppWeb.LayoutView do
  use AppWeb, :view
end

And... let's try again!

HTML Helpers

Still not working amiright?

It's because we still have to include the core helpers of phoenix_html into all our views.

In app_web.ex, edit the view_helpers function to make it look like the following:

  defp view_helpers do
    quote do
      use Phoenix.HTML
      import Phoenix.LiveView.Helpers

      # Import basic rendering functionality (render, render_layout, etc)
      import Phoenix.View

      import AppWeb.ErrorHelpers
      import AppWeb.Gettext
      alias AppWeb.Router.Helpers, as: Routes
    end
  end

You can refresh and it's now all working!

Make it stylish!

We now have our HTML page rendered! That's cool but we are not in the 80s anymore. Let's add some JS and CSS to make it more stylish!

Setup

You can create a new 'assets' folder and basic files

$ mkdir assets
$ cd assets
$ mkdir js scss dist
$ touch js/app.js scss/app.scss

Make sure that you also include the basic JS code requires to make liveview run:

import "phoenix_html"

// Establish Phoenix Socket and LiveView configuration.
import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken } })

window.addEventListener("phx:page-loading-start", info => console.log('Live view loading starts'))
window.addEventListener("phx:page-loading-stop", info => console.log('Live view loading ends'))

// connect if there are any LiveViews on the page
liveSocket.connect()

// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000)  // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket

Build your assets

I am using Vite to build the assets. To do so, create a package.json that look likes the following:

{
  "repository": {},
  "description": " ",
  "scripts": {
    "build": "vite build",
    "watch": "vite build --watch --minify false --emptyOutDir false --clearScreen false --mode development"
  },
  "dependencies": {
    "@popperjs/core": "^2.10.1",
    "bootstrap": "^5.1.1",
    "phoenix": "file:../deps/phoenix",
    "phoenix_html": "file:../deps/phoenix_html",
    "phoenix_live_view": "file:../deps/phoenix_live_view"
  },
  "devDependencies": {
    "sass": "^1.42.1",
    "typescript": "^4.4.4",
    "vite": "^2.5.10"
  }
}

And create a vite.config.js file:

export default {
  publicDir: "./static",
  build: {
    target: "es2018",
    minify: true,
    outDir: "../priv/static",
    emptyOutDir: true,
    assetsInlineLimit: 0,
    rollupOptions: {
      input: ["app/styles/main.scss", "./core.js", "app/app.ts"],
      output: {
        entryFileNames: "dist/[name].js",
        chunkFileNames: "dist/[name].js",
        assetFileNames: "dist/[name][extname]"
      }
    },
  }
}

Make sure to edit the file to make it match your folder architecture.

Live reload !

Now we have everything working well, we want to setup a live reload to refresh automatically all JS/CSS code.

First, add this to your config/dev.exs:

config :app, AppWeb.Endpoint,
  live_reload: [
    patterns: [
      ~r"priv/static/.*(js|css|scss|png|jpeg|jpg|gif|svg)$",
      ~r"priv/gettext/.*(po)$",
      ~r"lib/ks_web/(live|views)/.*(ex)$",
      ~r"lib/ks_web/templates/.*(eex)$"
    ]
  ]

Then in endpoint.ex edit the if code_reload? block to look like the following:

  if code_reloading? do
    socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
    plug Phoenix.LiveReloader
    plug Phoenix.CodeReloader
    plug Phoenix.Ecto.CheckRepoStatus, otp_app: :app
  end

And with this working, you now have a working web application!

I hope this has been helpful to you. I had to figure out each piece while doing it myself. Hopefully, this made your day. Cheers !

If you have a problem and no one else can help. Maybe you can hire the Kalvad-Team.