Jonnie Hallman is a designer and developer based in Brooklyn, working on stripe.com at Stripe.

His work focuses on building thoughtful, intuitive, and delightful interactions for the web, with a devotion to process, transparency, and sharing what he learns.


I’ve been holding off on applying a max-width to the site, but I figured now that I have 10 articles under my belt, the time is right. In the past, I’d honestly pick article width by choosing an ideal image width. This time, however, I’m focusing on readability. I remember learning in a typography class that there’s a rule of thumb for the ideal number of words on a line of text, which (I believe) was roughly 12 words. I received a tweet from @trey suggesting a max-width using the ch unit, which made me realize there’s a ch unit in CSS—I had no idea. Mozilla describes it as:

“The advance measure (width) of the glyph "0" of the element's font.”

MDN web docs - CSS values and units

Perfect. Now, I simply need to find the ideal number of characters on a line of text. A quick google search ..led me to this gem—The Elements of Typographic Style Applied to the Web. It quotes the book that the website is based on with our answer:

“The 66-character line (counting both letters and spaces) is widely regarded as ideal.”

The Elements of Typographic Style by Robert Bringhurst

Combining my newfound knowledge of the ch unit and the golden number 66, I set the site’s max-width to 66ch. It feels good, but the default <body> margin feels tight beyond mobile. I doubled the margin once it reaches the max-width, which feels much better. I don’t expect to keep this margin long-term, but this entire site is a series of small steps in the name of progress.

Syntax highlighting

In the previous article about WebP fallback, I wrote my first multiline code snippet on the site. In order to get the lines and spaces to work as expected, I added white-space: pre; to the <code> blocks that was generated from Contentful. It looked fine on desktop, so I hit “publish”, and went about my day. Later on, when showing a friend the site on my phone, I realized that the multiline code blocks caused a horizontal overflow on smaller viewports—how embarrassing!

Foreseeing future articles with multiline code snippets, I figured I should look into syntax highlighting. I didn’t spend too much time researching libraries before landing on Prism.js. I wrote a custom Contentful node renderer to intercept the <code> tag and reformat it using Prism.js. First, I detected if the code snippet is multiline or inline by checking for any newlines. If it’s multiline, I wrap the <code> block with a <pre> tag.

Prism supports about 200 programming languages, so I can highlight pretty much any language by specifying it. By default, I use markup as the language to handle generic code snippets, but I built a simple syntax for specifying the language, which looks like javascript:`const foo = 'bar';`. It’s not entirely standard, but works for now. Here’s an example of a multiline code block, which uses the default markup language:

  <h1 class='title'>Hello world!</h1>
  <p class='body'>This is my code snippet.</p>

And an example using a specified language:

import Foo from 'bar';

const foo = new Foo({ el: document.querySelector('#foo') });

Syntax highlighting is great, but it comes at a cost. Already, my Lighthouse score has dipped, so I’ll be looking at ways to improve it. The main issues involve color contrast for accessibility—an easy fix—and first meaningful paint, which might require some work. I imagine I’ll need to pull syntax highlighting out of the server-side rendering, so it executes asynchronously. I’m not too worried, but I am happy with this progress.

WebP fallback on Safari

Having images that resize correctly on mobile is great, but for some reason, they weren’t loading on Safari mobile—only the alt text would appear. This means either the image isn’t loading or the browser can’t render the image. Since the images are delivered by Contentful’s CDN, and the images loaded fine on desktop, I could easily cross that off my list. Next, I considered the image format. Is it possible that in 2019, Safari (let alone Safari mobile) doesn’t support WebP? I realize now that the title is a big spoiler, but it’s true. Images weren’t loading on Safari mobile nor desktop Safari.

Initially, I looked for an auto option for Contentful’s image format parameter, but none existed. Then, after a quick Google, I came across srcset—an HTML attribute I’ve been waiting a long time to find a use for. Amongst other things, this attribute lets you specify an ideal image format that might not be supported by all browsers, so if the format isn’t support, the browser falls back to another specified format. The actual implementation might look like this:

  <source type='image/webp' srcset='myimage.webp'>
  <img src='myimage.png'>

The source tag also lets you specify different images for varying media queries, but I don’t need those—yet. For the time being, this works perfectly well for me.

Responsive images

While resizing the browser, I noticed that my images weren’t resizing as well. I immediately knew the issue, but it made me laugh to think that such a simple, mindless setting that you set once and never revisit can sometimes slip through the cracks—img { width: 100% }.

Typically, to go along with this setting, you’d have a max-width on the image to prevent the it from scaling beyond its original width, but fortunately, Contentful’s image API also lets you reference an asset’s dimensions. Instead of setting the `max-width` to 100% and having my retina-ready image scale beyond its retina size, I set it to `${node.data.target.fields.file.details.image.width / 2}px`, which is a mouthful, but does the job. Now, my images are always true retina, and they scale with the viewport.


Continuing to make progress, little by little, I added dates to articles. In Contentful, this meant creating a new date field on the article’s content model, which is easy enough, but surprisingly, you can’t set the default date to the current date. This adds an extra step to the authoring flow, but it’s not the end of the world.

On the frontend, I added a <time> element under the article template’s title. This element takes a datetime attribute for a machine-readable format of the date, which lets you use any human-readable format for the element’s contents. For date formatting, I’m using date-fns, a lightweight date utility library that I also use in my app, Cushion.

The latest date-fns release only works with integers or Date objects, so first I needed parse Contentful’s dates. Previously, you could use the date-fns/parse method on any string, and it would handle it. Now, you need to pass the format of the date that you’re parsing. For Contentful’s dates, this looks like parse(article.fields.date, "yyyy-MM-dd'T'HH:mmXXX", new Date). The last argument here is a “backup date” required by date-fns to provide context to the original date, in case you’re parsing a date format with only 2-digit years. In my case, since the Contentful date format is standard, I can blindly pass the method a new Date object simply to prevent a missing argument error. Now that I have a parsed date, I can format the date into a human-readable format. For now, I’m using MMMM do, yyyy, which outputs to “November 3rd, 2019” for today’s date.

Along with the dates on the site itself, I also added the dates to my RSS feed by included a date property when calling addItem in the @nuxt/feed configuration. This method takes a Date object, so I needed to parse Contentful’s date again, but no need to format this time.

When I added all the new date fields to my Contentful entries, I noticed that the order changed. Because I wasn’t specifying an order when retrieving the entries, they used the updatedAt date, so when I set the date field on all the entries, that changed the order. Now that I have my own date field, the fix was as easy as setting the order property to '-fields.date,-sys.createdAt', which uses my new field, and falls back to the created timestamp.

  1. Actually, the native Date constructor can parse Contentful’s timestamps on its own without the need for date-fns’s parse method. Simply use new Date(article.fields.date).


I was all ready to publish the previous post, but noticed that the images weren’t showing up. After inspecting the rendered HTML, I realized Contentful’s documentToHtmlString method doesn’t include a renderer for images (or assets). This method is part of the @contentful/rich-text-html-renderer package, which takes an entry’s payload—a serious amount of JSON—and maps it to rendered HTML. Looking at the documentation, I found that you can configure custom renderers to handle nodes that aren’t mapped, like assets.

For now, I set up a dead-simple renderer that assumes the asset is an image, and renders a <img> tag with the title as the alt attribute. After deploying the update, I ran Lighthouse again, and noticed that my score went down. There was a new alert indicating that I’m not using modern image formats, like webp. On a whim, I googled “contentful webp”, and discovered that Contentful has a very nice asset API, which includes specifying a format, like webp. I appended ?fm=webp to my renderer, that was that. My Lighthouse score is back up, and I’m serving webp images by default. The modern web is a beautiful thing.

Lighthouse, Now build env vars, and Nuxt manifest

In digging around Now, I discovered a Lighthouse integration, which automatically audits your site builds for performance and accessibility. I noticed that my still bare website didn’t have a perfect score out of the box, so I dug deeper and found that both my site and my PWA manifest were missing a title and description. I’m certain I set these as META_TITLE and META_DESCRIPTION environment variables when realizing that Now doesn’t carry over the process.env.npm_package_ env vars, so I’m not crazy, but it turns out my assumption that setting the top-level env vars in the now.json sets them for both build and runtime is incorrect. By removing my build.env configuration in that file also removed my env vars on build, leaving me with a blank title and description. I copy/pasted my env var configuration to appear twice as env and build.env, and it worked. Great, but now I guess I need to map my env vars twice whenever I add a new one—not so great.

I went to check the Lighthouse report for my new build, but it simply said “No report available”. This consistently happened with other builds, so I decided to run Lighthouse in the Chrome dev tools instead. I still had almost a perfect score, but my PWA manifest was still missing a title, description, and short name (used for mobile app icons). I dug into the @nuxt/pwa module, and found that they default these values to the process.env.npm_package_ env vars that Now doesn’t recognize. Simply setting these properties to my META_ env vars worked as expected, and I now had a valid PWA manifest file. I ran Lighthouse again (on desktop), and was closer than I’ll probably ever be, with 100s across the board except for a lone 99 on performance—for a 340ms “Max Potential First Input Delay”.

Lighthouse desktop audit (11/01/2019)

Researching this for a bit, I discovered that it’s par for the course with SSR websites. If I have more time to dig deeper and find a potential fix, I will, but for now, I moved on. Lighthouse also has an option to audit your website on mobile, so I ran that report, and was delighted with a flawless victory and animated fireworks on a dark theme. This felt like I truly accomplished something (rather than made sure all my env vars were set)—thanks, Lighthouse team.

Lighthouse mobile audit (11/01/2019)

Title and article links

To continue to make progress, I took care of some low-hanging fruit by linking up the article titles to individual routes and adding an h1 to the site. Now, you can link to an article and return to the root by clicking the site title. These are so small that you probably wouldn’t even factor them into the scope of a site, but it feels good to recognize them as yet another step forward.

RSS feed

I was so busy setting up the site backend that I forgot to generate an RSS feed for it. (Thanks, @rectangular, for the reminder). Surprisingly, or maybe not surprisingly, this wasn’t easy. After realizing I couldn’t create the RSS feed using a template in Nuxt, I installed @nuxt/feed, which automatically generates the feed by setting a handful of options and an async function in the nuxt.config.js file.

Since I’d need to fetch entries from Contentful in the async function, I refactored the plugin, so I could require the relative file in the config file. This worked in local dev, but crashed my site on Now. After an hour of troubleshooting and research, I realized that when Now converts the site to serverless, it doesn’t carry over any files besides the config file and those generated in the public folder—you have to specify which server files to include by adding { "serverFiles": ["plugins/**"] } to the now.json file’s builds object.

Okay. This worked, but now my RSS feed wouldn’t display any entries. At first, I thought this might be another access token issue, but the undefined environment variable I referenced in the feed clued me to revisit env vars. It turns out Now has two spots to list environment variables in the now.json file—a top-level env object and another one at build.env. All my variables were set on build.env, which only exposes these variables during the build phase, not runtime—makes sense. Listing them in the top-level env objects exposes them for both build and runtime.

Now, I have a working RSS feed, but still don’t have routes for individual articles, so those links in the feed currently 404. I’ll hook that up next.

Contentful preview API hiccup

Before getting into any serious code, I’ve already experienced several hiccups so far. This most recent one involves setting up the site to use Contentful’s preview API in my local dev environment, which allows me to see draft content. After swapping out my access tokens, I could only get 401 errors.

In retrospect, I blame this hiccup solely on myself for neglecting to thoroughly read the manual. I have a tendency to only glance at documentation before diving in. In doing so, I only updated my accessToken to use the preview API token, but missed the part about specifying the host as preview.contentful.com, too. This totally makes sense, and I’m embarrassed I even reached out to support about this. For what it’s worth, I appreciate Stripe’s use of prefixes in their tokens to specify the environment (e.g., pk_test_), so a host option isn’t even necessary.

  1. I learned after the fact that the host option is necessary here because Contentful’s delivery API serves from the CDN while the preview API doesn’t.