Realtime native app scaffolding with nextjs, phoenix channels and capacitorjs

Updated on: Sat Sep 02 2023

Hello there! Recently I did some experiments with elixir phoenix live view and was excited how whole concept works out there. I will not go into details about benefits of it but will just highlight one drawback - it's not possible to build application statically and host as static site and that's why it's hard (or may be even not possible) to wrap live view app into solutions like capacitorjs to have it distributed as native (or kinda native) mobile application.

That's why I decided to try utilize phoenix channels as the thing to power realtime nature of application and base frontend on top of production ready solution like nextjs. My goal is still to be able to create native like experience with technologies I know and like to use.

Prerequisites

To make whole thing to work we'll need two runtimes - nodejs and elixir. Nowadays I use ubuntu as my personal system so we have an assumption that everything mentioned in this post was tried out in that OS. However most of recommendations should still be applicable for MacOS based systems.

Bootstrap client application

I will not go into details on how to install this or that tool or configure your operating system. There are lots of others tutorials about this in internet.
Let's assume we have pnpm configured in your system and right away kickoff the skeleton of our client app.

copied bash

cd ~/projects
pnpm create next-app

When executing this command do set your preferred settings there. For example I use typescript, no eslint, tailwind, src directory, app router and have default import alias.
Now we can cd to our app and try it out.

copied bash

cd my-test-app
pnpm dev

We should see now default nextjs layout in browser.

Bootstrap capacitorjs wrapper

Our main goal is to have application which could be published to stores and installable as native application. For this we'll use capacitorjs. Here I'll just put steps on how to wrap current application into capacitorjs. To have inspiration on how to configure capacitorjs, android studio please do follow amazing quick start tutorial on capacitorjs official website.
First let's install capacitor core, android capacitor sdk and cli, initialize capacitor in current project and initialize android project there.

copied bash

pnpm add @capacitor/core
pnpm add @capacitor/android
pnpm add -D @capacitor/cli
pnpm exec cap init
pnpm exec cap add android

We now should see following error

copied bash

The web assets directory (./public) must contain an index.html file.
It will be the entry point for the web portion of the Capacitor app.

This error comes from several facts. We did not have any builds of our app yet. Capacitor by default expecting statically built web application in ./public folder. Let's fix this. First of all we'll aim for our app to be fully statically built and be able to be distributed via just copy paste of full bundle directory. For that we have to explicitly configure that in next.config.js via output directive to have "export" value.

copied javascript

const nextConfig = {
  output: 'export'
}

Having this configuration we'll get whole built application in out directory after we'll trigger build.

copied bash

pnpm build

We can either point nextjs to build to public folder or point capacitor to expect bundle from out folder. Let's stick with second one. For that we need to put changes in capacitor.config.ts. Pay attention to webDir field.

copied ts/tsx

const config: CapacitorConfig = {
  appId: 'com.my_test_app.app',
  appName: 'my-test-app',
  webDir: 'out', // this one
  server: {
    androidScheme: 'https'
  }
};

Now let's copy bundle to android project and run our app via android studio.

copied bash

pnpm exec cap sync
pnpm exec cap open android

We can click run button in android studio and see application running in android emulator.

Bootstrap phoenix project

To bootstrap phoenix project we'll use mix cli tool.

copied bash

mix phx.new my_test_app_server

Install all the dependencies. To be honest following yagni principle we should bootstrap project with --no-ecto but I assume that most probably we'll need database. To make development postgres instance available we can easily have one via docker - check this post for more information.
Let's generate our first channel.

copied bash

mix phx.gen.channel TestChannel

Follow instructions you see in console - you need to add socket handler to endpoint.ex and import user_socket.js in app.js. Check previous command output for more information.
Finish setup of database in config/dev.ex. Run instance of postgres and run phoenix server.

copied bash

mix phx.server

Most probably we'll see warning about unmatched topic

copied bash

[warning] Ignoring unmatched topic "room:42" in MyTestAppServerWeb.UserSocket

To fix this let's adjust our user_socket.js code to connect to topic which our server expects. According to code in test_channel_channel.ex expected topic name is "test_channel:lobby". Let's also add listener right away to be able to test whole request cycle from client to channel on the server and back. Here is the full code of user_socket.js.

copied javascript

import { Socket } from "phoenix";

let socket = new Socket("/socket", { params: { token: window.userToken } });

socket.connect();

let channel = socket.channel("test_channel:lobby", {});
channel.join();

hannel.on("shout", (payload) => {
  document.body.innerText = payload.data;
});

setTimeout(() => {
  channel.push("shout", { data: "This text came from shout" });
}, 1000);

export default socket;

If we'll launch our server and navigate to localhost:4000 via browser after 1 second we should see "This text came from shout".

Connect to channels from nextjs client

To be able to consume data from phoenix channel first of all we have to install phoenix package in our nextjs project.

copied bash

pnpm add phoenix
pnpm add -D @types/phoenix

Now let's add simple Timer component which will connect to socket, join channel and push time update messages via "shout" channel callback. Code is present in listing below:

copied ts/tsx

"use client";

import { useEffect, useState } from "react";
import { Socket } from "phoenix";

const Timer = () => {
  const [socket, setSocket] = useState<any>(null);
  const [currentTime, setCurrentTime] = useState<number | null>(null);
  useEffect(() => {
    const instance = new Socket("ws://localhost:4000/socket", {
      params: { token: (window as any).userToken },
    });
    console.log("instance of socket", instance);
    instance.connect();
    setSocket(instance);
  }, []);

  useEffect(() => {
    if (!socket) return;
    let channel = socket.channel("test_channel:lobby", {});
    channel
      .join()
      .receive("ok", (resp: any) => {
        console.log("Joined successfully", resp);
      })
      .receive("error", (resp: any) => {
        console.log("Unable to join", resp);
      });

    channel.on("shout", (payload: any) => {
      console.log("Got message", payload);
      setCurrentTime(payload.data);
    });
    setInterval(() => {
      channel.push("shout", { data: Date.now() });
    }, 1000);
  }, [socket]);
  return <div>Current time is: {currentTime}</div>;
};

export default Timer;

Some notest regarding listing above. "use client" directive is needed to point nextjs that component is intentionally supposed to be used on client side.

There are two useEffect hooks. One to setup socket connection. Second is to join the channel and setup callbacks and launch periodical time update. Now import this component and render it somewhere on main screen. Open both frontends - phoenix one and nextjs one. You now should see timestamp magically updates every second in both places in realtime.

Congratulations! This is success!