Rebuilding a PHP App using Isomorphic JavaScript with Eleventy & Netlify

Painting of a cute red robot looking at itself in a full-body mirror.
Credit: Aaron Gustafson × DALL·E

Back in the early days of the iPhone, I created Tipr, a tip calculator that always produces a palindrome total.1 This is an overview of the minimal work I did to make it a modern web app that can run without a traditional back-end.

What I had to work with

The previous iteration of Tipr was built in my hotel room while I was on site doing some consulting for a certain Silicon Valley company. I was rocking a Palm Treo 650 at the time and that day a few of my colleagues had lined up to wait for the release of the very first iPhone. At the time, web apps were the only way to get an “app” on the iPhone as there was no SDK or even an App Store.

Tipr on the 1st generation iPhone, in the hands of Micah Alpern, June 2007.

I did a lot of PHP development back in the day, so armed with all of the mobile web development best practices of the day, I set about building the site and making it speedy. Some of the notable features of Tipr included:

  • Inlining CSS & JS file contents into the HTML.
  • Using PHP output buffers to compress the HTML on the server before sending it over the wire.
  • Server side processing in PHP.
  • Client side processing via XHR to a PHP-driven API.

At the time, most of these approaches were very new. As an industry, we weren’t doing a whole lot to ensure peak performance on mobile because most people’s mobile browsers were pretty crappy. This was the heyday of Usablenet’s “mobile friendly” bolt-on and WAP. Then came Mobile Safari.

Tipr in the original Apple App Store, back when web apps were first class citizens on iPhone OS.

A lot has changed since 2007

The Tipr site has remained largely untouched since I built it in the Summer of 2007. That October, I added a theme switcher that made the site pink for October (Breast Cancer Awareness Month). I added a free text message-based interface using the then-free TextMarks service and a Twitter bot as well. But as far as the web interface went, it remained largely untouched.

Here are a handful of things that have come to the web in the intervening years:

  • HTML5 Form Validation API
  • SVG support
  • CSS3
  • Media Queries
  • Web App Manifest
  • Service Worker (and its precursor the AppCache)
  • Flexbox
  • CSS Grid

Phew, that’s a lot! While I haven’t made upgrades in all these areas, I did sprinkle in a few, mainly to make it a true PWA and boost performance.

Moving from PHP to a “static” site

Much of my work over the last few years has been in the world of static site generators (e.g., Jekyll, Eleventy). I’m quite enamored of Eleventy, having used it for a number of projects at this point. Since I know it really well, it made sense to use it for this project too. The installation steps are minimal and I already had a library of configuration options, plugins, and filters to roll with.

While in the process of migrating to Eleventy, I also took the opportunity to

  • Swap raster graphics for SVGs,
  • Set up a Web App Manifest,
  • Add a Service Worker, and
  • Update the site’s meta info to reflect current best practices.

I also swapped out the PHP logic that governed the pink color theme for a simple script in the head of the every page. Since the color change is an enhancement, rather than a necessity, I didn’t feel like it was something I needed to manage another way.

The greatest challenge in moving Tipr over to a static site was setting up the tip calculation engine, which had been in PHP to ensure it would work even if JavaScript wasn’t available.

Migrating the core logic to isomorphic JavaScript

When I originally built Tipr, JavaScript on the back-end wasn’t a thing. That’s why the core tip calculation engine was built in PHP. At the time, even XHR was in its infancy, so the fact that I could use PHP to do the calculations for both the server-side—for when JavaScript wasn’t available—and client-side—when it was—was pretty amazing.

Today, JavaScript is ubiquitous across the whole stack, which made it the logical choice for building out the revised tip calculator. As with the original, I needed the calculation to work on the client side if it could—saving a round trip to the server—but to also have the ability to fall back to a traditional form submission if the client-side approach wasn’t feasible. That would be possible by having client-side JavaScript for the form itself, with the server-side piece handled by Netlify’s Edge Functions (integrated through Eleventy’s Edge plugin).

From an architectural standpoint, I really didn’t want to have my logic duplicated in each place, so I began to play around with ensconcing the calculation logic in a JavaScript include, so I could import it into the form page itself and a JavaScript module that the Edge Function could use.

You can view Tipr’s source on GitHub, but here’s a basic rundown of the relevant directories and files:

netlify
edge-functions
tipr.js
src
_includes
js
tipr.js
j
process.njk
index.html
netlify.toml

src/_includes/js/tipr.js

This file contains the central logic of the tip calculator. It’s written in vanilla JavaScript with the intent that it would be understandable by the widest possible assortment of browsers out there.

src/index.html

The homepage of the site is also home to the tip calculation form. Below the form is an embedded script element containing the logic for interacting with the DOM for the client-side version of the tip calculator. I include the logic at the top of that script:

<script>
{% include "js/tipr.js" %}

// The rest of the JavaScript logic
</script>

src/j/process.njk

This file exists solely to export the JavaScript logic from the include in a way that it can be consumed by the Edge Function. It will render a new JavaScript file called “process.js” and turns the central processing logic into a JavaScript module that Deno can use (Deno powers Netlify’s Edge Functions):

---
layout: false
permalink: /j/process.js
---

{% include "js/tipr.js" %}

export { process };

netlify/edge-functions/tipr.js

We define Edge Functions for use with Netlify in the netlify/edge-functions folder. To make use of the core JavaScript logic in the Edge Function, I can import it from the module created above before using it in the function itself:

import { process } from "./../../_site/j/process.js";

function setCookie(context, name, value) {
context.cookies.set({
name,
value,
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
});
}

export default async (request, context) => {
let url = new URL(request.url);

// Save to cookie, redirect back to form
if (url.pathname === "/process/" && request.method === "POST")
{
if ( request.headers.get("content-type") === "application/x-www-form-urlencoded" )
{
let body = await request.clone().formData();
let postData = Object.fromEntries(body);

let result = process( postData.check, postData.percent );

setCookie( context, "check", result.check );
setCookie( context, "tip", result.tip );
setCookie( context, "total", result.total );

return new Response(null, {
status: 302,
headers: {
location: "/results/",
}
});
}
}

return context.next();
};

What’s happening here is that when a request comes in to this Edge Function, the default export will be executed. Most of this code is directly lifted from Netlify’s Edge Functions demo site. I grab the form data, pass it into the process function, and then set browser cookies for each of the returned values before redirecting the request to the result page.

On that page, I use Eleventy’s Edge plugin to render the check, tip, and total amounts:

{% edge %}
{% set check = eleventy.edge.cookies.check %}
{% set tip = eleventy.edge.cookies.tip %}
{% set total = eleventy.edge.cookies.total %}
<tr id="check">
<th scope="row">Check&nbsp;</th>
<td>${{ check }}</td>
</tr>
<tr id="tip">
<th scope="row">Tip&nbsp;</th>
<td>${{ tip }}</td>
</tr>
<tr id="total">
<th scope="row">Total&nbsp;</th>
<td>${{ total }}</td>
</tr>
{% endedge %}

Side note: The cookies get reset using a separate Edge Function.

netlify.toml

To wire up the Edge Functions, we use put a netlify.toml file in the root of the project. Configuration is pretty straightforward: you tell it the Edge Function you want to use and the path to associate it with. You can choose to associate it with a unique path or run the Edge Function on every path.

Here’s an excerpt from Tipr’s netlify.toml as it pertains to the Edge Function above:

[[edge_functions]]
function = "tipr"
path = "/process/"

This tells Netlify to route requests to /process/ through netlify/edge-functions/tipr.js. Then all that was left to do was wire up the form to use that endpoint as its action:

<form id="calc" method="post" action="/process/">

Isomorphic Edges

It took a fair bit of time to figure this all out, but I’m pretty excited by the possibilities of this approach for building more static isomorphic apps. Oh, and the new site… is fast.


  1. Why a palindrome? Well, it makes it pretty easy to detect tip fraud because all restaurant totals will always be the same forwards & backwards. It’s a little easier than a checksum. ↩︎


Webmentions

  1. @Aaron Completely unmentioned in your post: you had dark mode before it was cool.

  2. @tomayac Based on the now-defunct idea that dark pages used less energy. 😔

  3. @tomayac @Schepp Good to know it's reality now. I remember some articles from the time I made the app (2007), saying black actually uses more energy than white on certain screen types.

    This was also around the time Google had done a dark version of the search homepage.

  4. @Aaron: good read & impressive speed scores.

    What is the difference between 'isometric' and 'isomorphic' as used in this article? I have not come across these terms before in this context (.. or if I have I've forgotten what they mean).

Shares

  1. Hugh
  2. Alex Russell
  3. Paweł Precz
  4. Tyler Sticka