Tutorial: Basic Full Stack App With Scala 3
The goal of this tutorial is to setup a fullstack app in Scala 3. The idea of this tutorial is to learn and understand the different pieces needed for a fullstack app in Scala. Thus, we are going through each step and explaining our every choice. We also link to relevant documentation.
There are several components that you will need to have your fullstack app:
- a server that provides web services that are the backend of our app
- a client that works in the browser and provides a UI for the user and communicates with the backend
The architecture is simple, but we also need some supporting infrastructure:
- Our build tool needs to be able to build both projects as subprojects of the same project.
- We would like to have autoreload of the client when the code is updated
- We would like to have autoreload of the server when the code is updated
I’m assuming that you are using Linux, Mac OS X or Windows with WSL for this tutorial. Commands have been tested on a Mac using bash shell.
Setting Up the Server
Create the Project and Server Subproject
We start from scratch with a template for a Scala 3 project:
$ sbt new scala/scala3.g8
[info] resolving Giter8 0.16.2...
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
A template to demonstrate a minimal Scala 3 application
name [Scala 3 Project Template]: fullstack-scala3
Template applied in /Users/mundacho/temp/fullstack-laminar/./fullstack-scala3
This creates a Hello, World! project in Scala 3. Now, let’s customize it. First thing, we need a subproject at folder server
. We create it:
$ mkdir server
The next step is to move our src
folder to that new folder:
$ mv src server/
We now update our build.sbt
file. We do the following:
- Step 1: We create the new
server
subproject - Step 2: We aggregate the server project
// build.sbt
val scala3Version = "3.3.1"
lazy val root = project
.in(file("."))
.settings(
name := "fullstack-scala3",
version := "0.1.0-SNAPSHOT",
scalaVersion := scala3Version
)
.aggregate(server) // Step 2: We aggregate the server project
lazy val server = project // Step 1: We create this block
.in(file("./server"))
.settings(
name := "server",
Compile / run / fork := true,
libraryDependencies ++= Seq(
"org.scalameta" %% "munit" % "0.7.29" % Test
)
)
Until now, nothing new under the sun. We are just creating a simple Scala 3 project that happens to have a subproject.
Setup the Webserver and Webservice
For this tutorial, we are using:
If you are familiar with other tools please feel free to use them. circe has the advantage of being usable for both for the server and the client, and offers an integration with http4s. However, there are other choices such as uPickle and cask from the Li Haoyi ecosystem, or ZIO http and ZIO Json from ZIO. Whatever suits you better, as long as you can create a web service on it.
For us, we can just go and checkout the documentation of http4s and modify our server
project accordingly.
// the root part above stays the same
// build.sbt
val http4sVersion = "0.23.24"
val circeVersion = "0.14.6"
lazy val server = project
.in(file("./server"))
.settings(
name := "server",
Compile / run / fork := true,
scalaVersion := scala3Version,
libraryDependencies ++= Seq(
"org.http4s" %% "http4s-ember-client" % http4sVersion,
"org.http4s" %% "http4s-ember-server" % http4sVersion,
"org.http4s" %% "http4s-dsl" % http4sVersion,
"org.slf4j" % "slf4j-simple" % "2.0.9",
"org.http4s" %% "http4s-circe" % http4sVersion,
"io.circe" %% "circe-generic" % circeVersion,
"org.scalameta" %% "munit" % "0.7.29" % Test
)
)
Now that we have web server, we needs some service. We will serve a list of products. Let us create a model and a data access object to feed our web service in the ProductDAO.scala
file:
// ProductDAO.scala
package com.idiomaticsoft.app
case class Product(code: String, description: String, price: Double)
object ProductDAO:
def findProducts(): List[Product] =
List(
Product("CODE1", "Product 1", 10),
Product("CODE2", "Product 2", 130),
Product("CODE3", "Product 3", 200),
Product("CODE4", "Product 4", 200),
Product("CODE5", "Product 5", 200)
)
Finally, we update our main class:
// Main.scala
package com.idiomaticsoft.app
import cats.effect.IO
import cats.effect.IOApp
import cats.effect.*
import com.comcast.ip4s.*
import io.circe.generic.auto.*
import io.circe.syntax.*
import org.http4s.*
import org.http4s.circe.*
import org.http4s.dsl.io.*
import org.http4s.ember.server.EmberServerBuilder
import org.http4s.implicits.*
import org.http4s.server.Router
object Main extends IOApp.Simple:
// 1: Create the product service
val productService = HttpRoutes.of[IO] {
case GET -> Root / "products" =>
Ok(ProductDAO.findProducts().asJson)
}
// 2: Allocate a route to the service in the router
val httpApp = Router("/api" -> productService).orNotFound
// 3: Build the actual server
val server = EmberServerBuilder
.default[IO]
.withHost(ipv4"0.0.0.0")
.withPort(port"8080")
.withHttpApp(httpApp)
.build
// 4: Launch the server in the application loop
val run = for {
_ <- server.allocated
_ <- IO.never // this is needed so that the server keeps running
} yield ()
The new main class defines the following:
- A product service that serves th list of products from our
ProductDAO
. - A router that maps the service under the
/api
route. - The actual server, listening on all IPv4 interfaces on port 8080 and that uses our router
- A main method that launchs our server. The main method makes sure that it stays running indefinitely after launching the server.
We can now run our server using sbt with the following command:
$ sbt server/run
[info] welcome to sbt 1.9.7 (AdoptOpenJDK Java 11.0.9.1)
[info] loading settings for project global-plugins from plugins.sbt ...
[info] loading global plugins from /Users/mundacho/.sbt/1.0/plugins
[info] loading settings for project fullstack-scala3-build-build from metals.sbt ...
[info] loading project definition from /Users/mundacho/Desktop/fullstack-laminar/fullstack-scala3/project/project
[info] loading settings for project fullstack-scala3-build from metals.sbt ...
[info] loading project definition from /Users/mundacho/Desktop/fullstack-laminar/fullstack-scala3/project
[success] Generated .bloop/fullstack-scala3-build.json
[success] Total time: 2 s, completed Nov 23, 2023, 9:43:46 PM
[info] loading settings for project root from build.sbt ...
[info] set current project to fullstack-scala3 (in build file:/Users/mundacho/Desktop/fullstack-laminar/fullstack-scala3/)
[info] running (fork) com.idiomaticsoft.app.Main
[error] [io-compute-14] INFO org.http4s.ember.server.EmberServerBuilderCompanionPlatform - Ember-Server service bound to address: [::]:8080
You can test that the JSON encoder and service works by opening a terminal and running:
$ curl localhost:8080/api/products
[{"code":"CODE1","description":"Product 1","price":10.0},{"code":"CODE2","description":"Product 2","price":130.0},{"code":"CODE3","description":"Product 3","price":200.0},{"code":"CODE4","description":"Product 4","price":200.0},{"code":"CODE5","description":"Product 5","price":200.0}]
Setting Up the Client
Scala JS has excellent documentation, we will be following the tutorial for Scala JS and Vite. Vite.js is a tool that allows to serve JS applications like a webserver, but can do some cool stuff like hot reloading modules and in particular your Javascript application. Yes, you are writing Scala, but you application is compile to standard JS in the end, and there, Vite.js is your friend.
Creating the Vite.js Project
You need to have installed node
and npm
to make this work. Once ther, you can create the client. From the root directory of your main project do:
$ npm create vite@4.1.0
✔ Project name: … client
✔ Select a framework: › Vanilla
✔ Select a variant: › JavaScript
Scaffolding project in fullstack-scala3/client...
Done. Now run:
cd client
npm install
npm run dev
Now, follow up as requested and do:
$ cd client/
$ npm install
added 9 packages, and audited 10 packages in 5s
3 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
We will modify a little bit the template before doing the npm run dev
, which will actually launch the Vite.js server.
Setting up our Template
We want our app to look cool, so we will download a template to make it better. We can get a free template from StartBootstrap.com. The bootstrap template contains the webpages, some JS code and CSS style sheets for your app. We remove some menus and replace the index.html
file from the vite.js
template with this one from the bootstrap template:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description"
content="" />
<meta name="author"
content="" />
<title>Simple Sidebar - Start Bootstrap Template</title>
<!-- Favicon-->
<link rel="icon"
type="image/x-icon"
href="assets/favicon.ico" />
</head>
<body>
<div class="d-flex"
id="wrapper">
<!-- Sidebar-->
<div class="border-end bg-white"
id="sidebar-wrapper">
<div class="sidebar-heading border-bottom bg-light">Start Bootstrap</div>
<div class="list-group list-group-flush">
<a class="list-group-item list-group-item-action list-group-item-light p-3"
href="#!">Demo</a>
</div>
</div>
<!-- Page content wrapper-->
<div id="page-content-wrapper">
<!-- Top navigation-->
<nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
<div class="container-fluid">
<button class="btn btn-primary"
id="sidebarToggle">Toggle Menu</button>
<button class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse"
id="navbarSupportedContent">
<ul class="navbar-nav ms-auto mt-2 mt-lg-0">
<li class="nav-item active"><a class="nav-link"
href="#!">Home</a></li>
<li class="nav-item"><a class="nav-link"
href="#!">Link</a></li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle"
id="navbarDropdown"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false">Dropdown</a>
<div class="dropdown-menu dropdown-menu-end"
aria-labelledby="navbarDropdown">
<a class="dropdown-item"
href="#!">Action</a>
<a class="dropdown-item"
href="#!">Another action</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item"
href="#!">Something else here</a>
</div>
</li>
</ul>
</div>
</div>
</nav>
<!-- Page content-->
<div class="container-fluid">
<h1 class="mt-4">App</h1>
<div id="app"></div>
</div>
</div>
</div>
<!-- Bootstrap core JS-->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Core theme JS-->
<script type="module"
src="/main.js"></script>
</body>
</html>
The bootstrap template also includes a CSS (style.css
) file, we just copy and paste it to the root path of our client folder, you can indeed just replace the one that the vite.js
template created for you. The file is too large to include here, but it is included in the template files under the css
folder. Under the js
folder in the bootstrap template there is a script.js
file. We copy and paste it into the root path of the client folder.
Finally, you need to replace the contents of the main.js
file included in the Vite.js template with the following:
import './style.css'
import './scripts.js'
import 'scalajs:main.js'
Now we have our UI ready but Vite.js does not know about Scala JS, and our own build.sbt
does not know about our Scala JS project.
Setting Up Sbt
Let’s start with sbt. The first thing is to setup the ScalaJS plugin. This is done by creating the file plugins.sbt
in the project
folder of our root project (not the client nor the server, the actual root):
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.14.0")
Then, we add the configuration to the build.sbt
file:
import org.scalajs.linker.interface.ModuleSplitStyle // 1. You need to import this
lazy val root = project
.in(file("."))
.settings(
name := "fullstack-scala3",
version := "0.1.0-SNAPSHOT",
scalaVersion := scala3Version
)
.aggregate(server, client) // 2. You need aggregate the client
// [..] server stays the same
lazy val client = project
.in(file("./client"))
.enablePlugins(ScalaJSPlugin)
.settings(
scalaVersion := scala3Version,
Compile / run / fork := true,
// Tell Scala.js that this is an application with a main method
scalaJSUseMainModuleInitializer := true,
/* Configure Scala.js to emit modules in the optimal way to
* connect to Vite's incremental reload.
* - emit ECMAScript modules
* - emit as many small modules as possible for classes in the "client" package
* - emit as few (large) modules as possible for all other classes
* (in particular, for the standard library)
*/
scalaJSLinkerConfig ~= {
_.withModuleKind(ModuleKind.ESModule)
.withModuleSplitStyle(
ModuleSplitStyle.SmallModulesFor(List("client"))
)
},
/* Depend on the scalajs-dom library.
* It provides static types for the browser DOM APIs.
*/
libraryDependencies ++= Seq(
"org.scala-js" %%% "scalajs-dom" % "2.4.0",
"io.circe" %%% "circe-core" % circeVersion,
"io.circe" %%% "circe-parser" % circeVersion,
"io.circe" %%% "circe-generic" % circeVersion
)
)
The configuration adds support for the scalajs-dom
library and the circe
library, the same we used for the server. The rest is the same as recommended in the Scala JS documentation for their demo project.
We then create our application in Scala and save it:
// client/src/main/scala/client/App.scala
package client
import scala.scalajs.js
import scala.concurrent.ExecutionContext.Implicits.global
import js.Thenable.Implicits.*
import org.scalajs.dom
import io.circe.syntax.*
import io.circe.generic.auto.*
import io.circe.parser.parse
// We redefine the Product class here. This can be done using a shared project
// but we keep it that way for simplicity reasons.
case class Product(code: String, description: String, price: Double)
@main
def HelloWorld(): Unit =
println("Hello World!")
val responseText = for {
response <- dom.fetch("http://localhost:5173/api/products")
text <- response.text()
} yield {
text
}
val app = dom.document.getElementById("app")
for {
text <- responseText
} yield {
val products = parse(text).flatMap(_.as[List[Product]]).toOption.get
app.innerHTML =
"""<table class="table"><thead><tr><th>Code</th><th>Desc</th><th>Price</th></tr></thead><tbody>""" + products
.map(x =>
s"<tr><td>${x.code}</td><td>${x.description}</td><td>${x.price}</td></tr>"
)
.mkString + "</tbody></table>"
}
We are now ready to compile it with sbt
, from the root directory of the project (not client, but the full project) run:
$ sbt client/compile
This compiles the client but you cannot still use the code. You can use Vite.js for that.
Configuring Vite.js to use Scala JS
You need now to add the ScalaJS plugin to Vite.js, this is done with the following command (from the client
project directory):
$ npm install -D @scala-js/vite-plugin-scalajs@1.0.0
Finally, create the vite.config.js
file and fill it with the following content:
import { defineConfig } from "vite";
import scalaJSPlugin from "@scala-js/vite-plugin-scalajs";
export default defineConfig({
base: "/",
plugins: [scalaJSPlugin(
{
cwd: "..",
projectID: "client"
}
)],
server: {
proxy: {
'/api': 'http://localhost:8080',
},
},
});
Now, you can open two terminals and in the first one run:
$ sbt server/run
This will run the server in the background on port 8080.
In the second:
$ npm run dev
This will run the application on http://localhost:5173/.
The Vite.js ScalaJS plugin will compile and run the Scala code for the client directly using sbt and you can see the new application in your browser.
The Vite.js configuration also includes a proxy that redirects requests from the client to the actual server. This is required because browser security features usually will not allow Javascript code in a website to call Javascript in another website.
Setting up auto-reload
We stated at the beginning that we wanted auto-reload both in the client and in the server. For the client, there is nothing else to do. You just need to launch the command:
$ sbt ~client/fastLinkJS
It will keep updating the client’s Javascript as you modify it and Vite.JS will find the modifications and update the view.
For the server, you need to put a bit extra effort. Indeed, the problem with the server is that when you launch the run command it will stay there running the server and it will not restart when you change the code. To get sbt to restart the server you need to use the sbt-revolver
plugin. Just go to your plugins.sbt
file and add the following line:
addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0")
You can now launch the server with:
$ sbt ~server/reStart
This will restart the server each time the code is changed.
Conclusions
In this tutorial we have shown the simplest app possible that includes both a client and a server in Scala. I hope you enjoyed it.
If you have read until here and liked this content please consider subscribing to my mailing list. I write regularly about Blockchain Technology and Scala.