Here are the key advantages of this approach:

  • Getting started is cca 10 seconds and you are live reloading your changes in browser. No config.

  • There is no bundling misdirection, so source maps work. You can debug scalaJS straight out of VSCode by following the standard VSSCode debugging instructions.

  • Because there's no seperate ecosystem or NPM to configure, configuring build and CI is a lot easier. No node_modules to worry about. I found that this simplicity infected everything around it.

  • In terms of performance; NPM dependancies are loaded out the CDN. This is slow the first time - but seriously - check the network browser tools when you refresh the page. The second time they are all served out of browser cache - it takes 0ms. Even better, that cache survives application redeployment!. If you pre-load the (fat) "internal-" xxx dependancies scalaJS produces, this combination crushes page load times.

  • You can use the same build tool for both backend and frontend, and share code between them.

  • Unit testing the frontend with the Playwright java client suddenly bursts into life...

    • And be because it's all orchestrated in the same process - you can test the styles and the interplay between the application state and styles which is where most of my bugs are.
  • Your build becomes very simple. Here is a mill build.sc file that builds an assembly, including your static resources, frontend app and API. Example project

object backend extends Common with ScalafmtModule with ScalafixModule {

  def ivyDeps =
    super.ivyDeps() ++ Config.jvmDependencies ++ Config.sharedDependencies

  def moduleDeps = Seq(shared.jvm)

  def frontendResources = T{PathRef(frontend.fullLinkJS().dest.path)}

  def staticAssets = T.source{PathRef(frontend.millSourcePath / "ui")}

  def allClasspath = T{localClasspath() ++ Seq(frontendResources()) ++ Seq(staticAssets())  }

  override def assembly: T[PathRef] = T{
    Assembly.createAssembly(
      Agg.from(allClasspath().map(_.path)),
      manifest(),
      prependShellScript(),
      Some(upstreamAssembly2().pathRef.path),
      assemblyRules
    )
  }
}

A dockerfile that uses the assembly:

FROM azul/zulu-openjdk-alpine:17

# See the GHA for building the assembly
COPY "./out/backend/assembly.dest/out.jar" "/app/app.jar"

EXPOSE 8080

ENTRYPOINT [ "java", "-jar", "/app/app.jar" ]

Here is a GHA, that deploys that assembly to fly.io.

name: Deploy to fly.io

on:
  push:
    branches: [main]

env:
  FLY_API_TOKEN: $
jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2
      - uses: coursier/setup-action@main
        with:
          jvm: temurin@17
          apps: mill
      - uses: superfly/flyctl-actions/setup-flyctl@master
      - name: Build application
        run: mill show backend.assembly -j 0
      - name: Deploy to fly.io
        run: flyctl deploy --remote-only

You are live.