Vanilla with Candy Sprinkles

A bird behind an ice cream counter adding a second scoop of ice cream on top of a vanilla cone.  On the bird's apron are the words 'Candy Sprinkles'
Image by Annie Ruygt

Recapping where we are to date:

Picking up where we left off, this blog post will describe literally dozens (and that’s actually an understatement as you will soon see) of considerably more, dare I say it, vanilla frameworks that you can assemble on your own and deploy to fly.io and elsewhere.

This can be overwhelming, so to make things easier we are going to define a baseline application that will be reimplemented to take advantage of various tools. The result will be:

  • Educational. Seeing a bite sized working example is a great way to learn how a tool works.
  • Useful starting point. Whereas large frameworks make a number of choices for you, being able to selectively include only the tools you need can provide you with a preconfigured configuration to build upon.
  • Debugging aid. When a large system doesn’t behave the way you want it to, being able to reproduce and debug the problems on a smaller base not only can help you quickly narrow down the problem, and also can be used as a test case for a bug report.

Let’s get started!

Baseline requirements

What we are looking for is a cross between Hello, World! and Rosetta Code, but for a full stack application. For our purposes, the baseline is a stateful web server. Ideally one that can be deployed around the globe, and can deliver real time updates. But for now we will start small and before you know it we will have grown into the full application.

A simple application that meets these requirements is one that shows a visitors counter. A counter that starts at one, and increments each time you refresh the page, return to the page, or even open the page in another tab, window, browser, or on another machine. It looks something like this:

welcome counter

As previously discussed, key to deployment is a package.json file that lists all of your dependencies, optional build instructions, and how to start your application. We are going to start very simple, with no dependencies and no build process, so the package.json file will start out looking like the following:

{
  "scripts": {
    "start": "node server.js"
  }
}

Now to complete this we are going to need not only a server.js file, but also HTML, CSS, and image(s). As with some of the cooking shows you see on the television, we are going to skip ahead and pull a completed meal out of the oven. Run the following commands on a machine that has node.js >= 16 installed:

mkdir demo
cd demo
npx --yes @flydotio/node-demo@latest

Once this command completes, you can launch the application with npm run start. If you have authenticated and have flyctl version 0.1.6 or later installed, you can launch this application with fly launch followed by fly deploy. When you run fly launch, consider saying yes to deploying a postgres and redis database as we will be using them later.

You can play with this right now.

Don’t have node installed or a fly.io login? Deploy using [Fly.io terminal](https://fly.io/terminal) or see our [Hands-on](https://fly.io/docs/hands-on/) guide that will walk you through the steps.

Try Fly for free

If you are running it locally, open http://localhost:3000/ in your browser. If you have deployed it on fly.io, try fly open. If you are running in a fly.io terminal, there is a handy link you can use on the left hand pane.

Now take a look at server.js. It is all of 72 lines, including blank lines and comments. In subsequent sections we show how to make it smaller using available libraries, and how to add features. But before we proceed, lets save time and keystrokes by installing the node-demo package, which we will use repeatedly to generate variations on this application:

npm install @flydotio/node-demo --save-dev

Starting small

If you look at the top of the server.js file you will see a number of calls to require(). This is Nodes CommonJS modules. Node also supports EMCAScript modules, which is what all the cool kids are using these days.

This requires opting in. You can let node-demo make the changes for you by running the following command:

npx node-demo --esm

This script will detect what changes need to be made, give you the option to show a diff of the changes, and to accept or reject the changes. This leads us to the second option: --force that will automatically apply the changes without prompting:

npx node-demo --esm --force

Relaunch your application locally using npm run start or redeploy it remotely using fly deploy.

Using a real template

Inside the application you can see that the HTML response is produced by reading a template file and replacing a placeholder string with the current count:

contents = contents.replace('@@COUNT@@', count.toString());

While this is fine for this example, larger projects would be better served with a real template. node-demo supports two such templating engines at the moment: ejs and mustache. Select your favorite, or switch back and forth:

npx node-demo --ejs

and

npx node-demo --mustache

Be sure to add --esm if you want to continue to use import statements.

A more substantial change

While node:http provides the means for you to create a capable HTTP server, it requires you to be responsible for status codes, mime types, headers, and other protocol details. express will take care of all of this for you:

npx node-demo --express

Both ejs and mustache have integrations with express. Try switching between the two to see how they differ.

A real database

Maintaining a counter in a text file is good enough for a demo, but not suitable for production. Sqlite3 and PostgreSQL are better alternatives:

npx node-demo --sqlite3

and

npx node-demo --postgresql

Sqlite3 is great for development, and when used with litefs is great for deployment. PostgreSQL can be used in development, and currently is the best choice for production.

To run with PostgreSQL locally, you need to install and start the server and create a database. For MacOS:

brew install postgresql
brew services start postgresql
psql -U postgres -c "drop database if exists $USER;"
psql -U postgres -c "create database $USER;"
export DATABASE_URL=postgresql://$USER:$USER@localhost:5432/$USER

Be as weird as you want to be

The next two options are frankly polarizing. People either love them or hate them. We won’t judge you.

First tailwindcss is a CSS builder that works based on parsing your class attributes in your HTML:

npx node-demo --tailwindcss

Next is typescript which adds type annotations:

npx node-demo --typescript

TypeScript should work with all of the options on this page, in many cases making use of development only @types. All of this should be handled automatically by node-demo.

Both of these require a build step, which can be run via npm run build. A change to the Dockerfile used to deploy is also required, which can be made using:

npx dockerfile

dockerfile-node is actually a separate project with its own options for you to explore.

Object Relational Mappers (ORMs)

Adding databases was the first change that we’ve seen that actually makes the demo application noticeably larger, particularly with PostgreSQL once the code that handles reconnecting to the database after network failures is included. This can be handled by including still more libraries, this time Object Relational Managers (ORMs). Three popular ones:

npx node-demo --drizzle

and

npx node-demo --knex

and

npx node-demo --prisma

Knex runs just fine with vanilla JavaScript. Prisma can run with vanilla JavaScript, but works better with TypeScript. Drizzle requires TypeScript.

Prisma and Drizzle also require a build step.

A final note: if you switch back and forth between Sqlite3 and PostgreSQL, you may get into a state where the migrations generated are for the wrong database. Simply delete the prisma or src/db/migrations directory and rerun the npx demo command to regenerate the migrations.

Real Time Updates

If you open more than one browser window or tab, each will show a different number. This can be addressed by introducing websockets:

npx node-demo --websocket

The server side of web sockets will be different based on whether or not you are using express. For the first time we are providing a client side script which is responsible for establishing (and reestablishing) the connection, and updating the DOM when messages are received. This is a chore, and htmx is one of the many libraries that can be used to handle this chore:

npx node-demo --htmx

The next problem is that if you are running multiple servers, each will manage their own pool of WebSockets so that only clients in the same pool will be notified of updates. This can be addressed by using redis:

npx node-demo --redis

At this point, if you are using fly.io, postgres, and redis, you can go global:

fly scale count 8 --region ams,syd,nrt,dfw

Packaging alternatives

So far, we have been using npm, but yarn and pnpm are alternatives that may be better for some use cases:

npx node-demo --yarn

and

npx node-demo --pnpm

Each package manager organizes the node_modules directory a bit differently, so for best results when switching, remove the node_modules directory before switching:

rm -rf node_modules

Windows Powershell users will want to use the following command instead:

rm -r -fo node_modules

Future explorations

While we have explored many options, this only scratches the surface. There are many alternatives to the libraries above, and many more things to explore. Examples:

  • React can be run server side in a number of different ways, and can be run client side using a CDN or self hosted scripts.
  • In addition to React, there are a number of client side libraries like Angular, Lit, SolidJS, Svelte, and Vue. Coupling this with bundlers like esbuild and rollup, perhaps in a monorepo using workspaces would make good starting points for larger projects.
  • I welcome alternate implementations of this demo, perhaps using decidedly non-vanilla frameworks as a starting point. I’m particularly interested in implementations that support real time updates and globally distributed replications. If we get enough, perhaps we can maintain a catalog of pointers to these implementations.
  • While this blog post has focused on local development and deployment on fly.io, there is no lock in here. Maintaining a catalog of pointers to blog posts that describe how to deploy this application elsewhere would be welcomed too. Again, bonus points for geographic distribution and real-time updates.

node-demo is open source, so issues, pull requests, and discussions are always welcome!

I hope you have found this blog post to be informative, and perhaps some of you will use this information to start your next application “vanilla” with your personal selection of toppings. Yummy!