Adding view count and like button to 11ty

21 Jun 2024

I like 11ty for its simplicity. Once you understand that its documentation is shit and you are left alone in this developer life to struggle in the ultimate quest to achieve enlightenment, you might even say that it's a good blogging framework that can teach you important lessons about craving and duḥkha.

A few months ago, I was eager to add some small features to my blog: a views count and a like button that viewers can click to give me some temporary pleasure before I slide back into fullness of emptiness.

So I started my quest with simple idea: add express as the backend, create some APIs, write data to a JSON file, and voila!????

flowchart LR subgraph static A([11ty]) --- B[Post1] A --- C[Post2] end subgraph dynamic B --- D[Views.njs] & E[Likes.njs] D & E <-.->F([express]) F <-.-> G[(Stats.json)] end

The issue with this approach is that I don't have my own server, just a free Vercel hobby account. Vercel doesn't allow writing to a file and directs you towards a more serverless approach, encouraging you to learn new things instead of staying comfortable with your 10-year-old mindset.

Cooking the solution #

Mixing 11ty with Express and Vercel serverless functions is like mixing cough syrup with iodine and lye . It takes time, but you might end up with something nice. Or, you could get frustrated and burn your house down. It took me about two working days to get it sorted properly, but the house is okay.

The issue lay in the Vercel itself - by default the framework is predicted from build script from the package.json file. In our case is clearly 11ty.

{

"name" : "dynamic-11th" ,

"version" : "1.0.0" ,

"scripts" : {

"build" : "eleventy" ,

"watch" : "eleventy --watch" ,

"start" : "node server.js"

} ,

"dependencies" : {

"@11ty/eleventy" : "^2.0.1" ,

"express" : "^4.17.1" ,

"@vercel/kv" : "0.2.1"

}

}

But we can override that by setting different options in the Project Build Settings. The only issue is that we cannot set there two separate build settings (static 11ty build and node execution). However, we can utilize the vercel.json file to achieve this

{

"version" : 2 ,

"builds" : [

{

"src" : "package.json" ,

"use" : "@vercel/static-build" ,

"config" : {

"distDir" : "_site"

}

} ,

{

"src" : "server.js" ,

"use" : "@vercel/node"

}

] ,

"routes" : [

{ "src" : "/api/.*" , "dest" : "/server.js" } ,

{ "src" : "/(.*)" , "dest" : "/_site/$1" }

]

}

So all we have to do now is create and populate server.js in our root folder with some basic API endpoints and a place to store the data. Fortunately, Vercel provides serverless Redis storage: Vercel KV , which we can easily utilize for that purpose.

Let's update the chart with more data so we know where we are right now

flowchart LR subgraph 11ty [11ty] C(postTitle) --- D[Views.njs] & E[Likes.njs] end subgraph node [node] F([express]) end subgraph Vercel [Vercel KV] subgraph Key [Key] G(postTitle) end subgraph Value [Value] H[likes] & I[views] end end Key --- Value F <-.-> Key D <-. API/JSON .-> F E <-. API/JSON .-> F

So now we just need to access the database and get or set the key. Let's assume that we already have postTitle , so our pseudocode can be simplified. To increase the value we use HINCRBY; to retrieve it, we use HGET. If the value does not exist, we use HSET.

const { kv } = require ( "@vercel/kv" ) ;

const views = await kv . hincrby ( postTitle , "views" , 1 ) ;

const likes =

( await kv . hget ( postTitle , "likes" ) ) || ( await kv . hset ( postTitle , "likes" , 0 ) ) ;

Note that even though the official Redis documentation states that these functions can be written in uppercase, the code within server.js is case-sensitive! Also, observe the brackets around @vercel/kv import. I wasted 3 hours looking for the reason why KV is not working properly.

Now the server.js can be populated with proper APIs that will handle get/post requests.

const express = require ( "express" ) ;

const { kv } = require ( "@vercel/kv" ) ;

const app = express ( ) ;

const PORT = process . env . PORT || 8080 ;

app . use ( express . json ( ) ) ;

app . get ( "/api/" , ( req , res ) => res . json ( { message : "Hello from Express!" } ) ) ;

app . get ( "/api/:postTitle/likes" , async ( req , res ) => {

try {

const { postTitle } = req . params ;

const likes =

( await kv . hget ( postTitle , "likes" ) ) ||

( await kv . hset ( postTitle , "likes" , 0 ) ) ;

res . json ( { likes : likes } ) ;

} catch ( err ) {

console . error ( err ) ;

res . status ( 500 ) . send ( "Error getting likes count!" ) ;

}

} ) ;

app . post ( "/api/:postTitle/likes" , async ( req , res ) => {

try {

const { postTitle } = req . params ;

const likes = await kv . hincrby ( postTitle , "likes" , 1 ) ;

res . json ( { likes : likes } ) ;

} catch ( err ) {

console . error ( err ) ;

res . status ( 500 ) . send ( "Error updating likes count!" ) ;

}

} ) ;

app . get ( "/api/:postTitle/views" , async ( req , res ) => {

try {

const { postTitle } = req . params ;

const views = await kv . hincrby ( postTitle , "views" , 1 ) ;

res . json ( { views : views } ) ;

} catch ( err ) {

console . error ( err ) ;

res . status ( 500 ) . send ( "Error getting post views!" ) ;

}

} ) ;

app . listen ( PORT , ( ) => console . log ( ` Server ready on port ${ PORT } . ` ) ) ;

module . exports = app ;

Now the best part. Putting it all together in the half-ass manner .

In the first line of Views.njk the data-postTitle should contain {{ title }} value, but unfortunately 11ty tries to render the post title there, so I had to improvise for clarity. In your solution, simply replace it with curly brackets.

< p id = " views " class = " views " data-postTitle = " << title >> " > </ p >

< script >

const viewsP = document . getElementById ( "views" ) ;

const postTitle = viewsP . dataset . postTitle ;

document . addEventListener ( "DOMContentLoaded" , async ( ) => {

const response = await fetch ( ` /api/ ${ postTitle } /views ` , { method : "GET" } ) ;

const data = await response . json ( ) ;

viewsP . textContent = data . views ;

} ) ;

</ script >

and LikeButton.njk with the same issue like before:

< button id = " likeButton " class = " likes " data-button-id = " << title >> " >

< span id = " likesCount " > </ span >

</ button >

< script >

const likeButton = document . getElementById ( "likeButton" ) ;

const likesCount = document . getElementById ( "likesCount" ) ;

const postTitle = likeButton . dataset . buttonId ;

document . addEventListener ( "DOMContentLoaded" , async ( ) => {

const response = await fetch ( ` /api/ ${ postTitle } /likes ` , { method : "GET" } ) ;

const data = await response . json ( ) ;

likesCount . textContent = data . likes ;

const handleClick = async ( ) => {

const response = await fetch ( ` /api/ ${ postTitle } /likes ` , { method : "POST" } ) ;

const data = await response . json ( ) ;

likesCount . textContent = data . likes ;

likeButton . removeEventListener ( "click" , handleClick ) ;

} ;

likeButton . addEventListener ( "click" , handleClick ) ;

} ) ;

</ script >

This code can be accessed like so {% include "likeButton.njk" %} on every page. You can check working demo and the code on GitHub. Now smash that like button below!