Back to Basics: Building a HackerNews Clone with Marko
Ryan Carniato
Posted on March 10, 2021
I've used a lot of frontend frameworks over the years for demos and benchmarks. But they all had one thing in common. They were made primarily for Single Page Apps (SPA).
Since I joined the Marko team I noticed we lacked a lot of the common examples you find in other frontend frameworks. And I figured implementing them could teach more about how current Marko works (while I'm occupied working on the next version).
I wanted to do a demo that was a bit more substantial than a TodoMVC. Something that had routing and API requests. But not something that was going to be too involved like Realworld Demo. So Hackernews (https://hnpwa.com/) seemed like the perfect fit.
This article aims to provide you the context to get a feel for authoring in Marko. I will share my thoughts at the end at how this experience changed my perspective on web development.
Getting Started
The easiest way to get started with Marko is to use the CLI. I ran npx @marko/create
and selected the basic template. I immediately emptied the page and component folders and I was good to go.
Interested to learn more about getting started with Marko CLI? Read A First Look at MarkoJS
First thing I set up was an app layout component in my components
folder. This was the first indicator things were going to be very different:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="description" content="Marko Hackernews"/>
<title>Marko - Hacker News</title>
</head>
<body>
<header class="header">
<nav class="inner">
<a href="/">
<strong>HN</strong>
</a>
<a href="/new">
<strong>New</strong>
</a>
<a href="/show">
<strong>Show</strong>
</a>
<a href="/ask">
<strong>Ask</strong>
</a>
<a href="/job">
<strong>Jobs</strong>
</a>
<a class="github" href="http://github.com/marko-js/marko" target="_blank" rel="noreferrer">
Built with Marko
</a>
</nav>
</header>
<${input.renderBody}/>
</body>
</html>
I was using the React and Solid implementations for reference and the very first thing I realized was that there is no client-side routing needed. No <Link>
or <NavLink>
component. I literally just wrote some <a>
tags. I mean I haven't done this for so long it felt weird. It was literally like writing an index.html file back in the day except I indicated where I wanted my content inserted by <${input.renderBody}/>
I appended my global styles to the bottom of the file as these demos tend to use global CSS to make it approachable (in our case we are theming it the same as the Vue HN example).
The other thing I knew was I'd need to tap into API. I made a small helper file in a lib
folder to create my requests against the node-hnapi. I chose this one as it reduces the number of individual requests. It doesn't support the "users" api so I used the original firebase one for that.
This was pretty much copy and paste from my other implementations:
import fetch from "node-fetch";
const mapStories = {
top: "news",
new: "newest",
show: "show",
ask: "ask",
job: "jobs",
};
const get = (path) =>
fetch(path, {
headers: { "User-Agent": "chrome" },
}).then((r) => r.json());
export function getStory(id) {
return get(`https://node-hnapi.herokuapp.com/item/${id}`);
}
export function getUser(id) {
return get(`https://hacker-news.firebaseio.com/v0/user/${id}.json`);
}
export function getStories(type, page) {
const l = mapStories[type];
if (!l) return [];
return get(`https://node-hnapi.herokuapp.com/${l}?page=${page}`);
}
There are is some weirdness here around user agent as these APIs reject fetches from the server unless you fake it. A lot of things I've hit before in these demos but really nothing out of the ordinary with Marko.
I have a lookup to map the page URLs to the API endpoint and I decided to use node-fetch
for my server-side fetching.
And that was it for my layout and API. Now to make the first page.
Building the Stories Page
The majority of this demo are pages that list all the stories for a given topic. So I started there. Marko's CLI with its automatic route detection let me name my page :stories
which serves as a catchall and passes anything that matches /:stories
to my page.
import { getStories } from "../../lib/api"
static function getPage(query) {
if (!query || !query.includes("page")) return 1;
return +query.split("=")[1];
}
$ const page = getPage(input.query);
<app-layout>
<div class="news-view">
<await(getStories(input.params.stories || "top", page)) client-reorder>
<@then|stories|>
<div class="news-list-nav">
<if(page > 1)>
<a
class="page-link"
href=`${input.pathname}?page=${page - 1}`
aria-label="Previous Page">
< prev
</a>
</if>
<else>
<span class="page-link disabled" aria-hidden="true">< prev</span>
</else>
<span>page ${page}</span>
<if(stories.length === 30)>
<a
class="page-link"
href=`${input.pathname}?page=${page + 1}`
aria-label="Next Page">
more >
</a>
</if>
<else>
<span class="page-link" aria-hidden="true">more ></span>
</else>
</div>
<main class="news-list">
<ul>
<for|story| of=stories>
<story story=story/>
</for>
</ul>
</main>
</@then>
<@placeholder>
<div class="news-list-nav">Loading...</div>
</@placeholder>
</await>
</div>
</app-layout>
The template on this page starts by using our layout. And from there uses an <await>
tag to fetch our data and handle placeholders. This is similar to Svelte's await
or React's Suspense.
Marko uses this idea of tag attributes <@__>
similar to render props in React so it was really easy to pass dynamic markup into our components and pass arguments to them. So <@then|stories|>
basically works like a function call. In JSX it would be the same as:
<Await
promise={getStories(input.params.stories || "top", page)}
then={(stories) => <main>...</main>}
placeholder={<div class="news-list-nav">Loading...</div>}
/>
I included a couple of helpers to inspect the input
parameters coming in from the URL so that I could get the page number off the query. This way we can handle pagination. You will see 2 new syntaxes here. Firstly static
is used to indicate that the function is part of the component declaration and is only created once for all instances. The second is $
which allows us to insert JavaScript expressions anywhere in the template.
So far this page doesn't feel very different than other libraries. I was mostly able to just copy and paste this into the <await>
tag and change the conditionals and loop to use Marko's <if>
and <for>
.
As for the stories themselves I made a re-usable story
component and within a couple mins had converted the JSX from my other implementations into this:
<li class="news-item">
<span class="score">${input.story.points}</span>
<span class="title">
<if(input.story.url)
>
<a href=input.story.url target="_blank" rel="noreferrer">
${input.story.title}
</a>
<span class="host"> (${input.story.domain})</span>
</if>
<else>
<a href=`item/${input.story.id}`>${input.story.title}</a>
</else>
</span>
<br />
<span class="meta">
<if(input.story.type !== "job")
>
by <a href=`users/${input.story.user}`>${input.story.user}</a>
${input.story.time_ago} |
<a href=`stories/${input.story.id}`>
${input.story.comments_count ? `${input.story.comments_count} comments` : "discuss"}
</a>
</if>
<else>
<a href=`stories/${input.story.id}`>${input.story.time_ago}</a>
</else>
</span>
<if(input.story.type !== "link")>
<span class="label"> ${input.story.type}</span>
</if>
</li>
Rename props
to input
here and replace a ternary with <if>
and <else>
there, replace <Link>
s with simple <a>
tags and we are set.
At this point, the app actually mostly works. The page loads up and you can see the loading state and then the content fills in. Can't click into a story, or view the user yet, but navigation and pagination are done. Really simply from file-based routing system and just reading the query off the input
.
The Other Pages
Other pages mostly follow the same pattern. The story page also requires a dynamic route. So under the pages directory, I made stories/:id
. Similar exercise again but a bit simpler as there are no URL query parameters.
import { getStory } from "../../../lib/api";
<app-layout>
<await(getStory(input.params.id)) client-reorder>
<@then|story|>
<div class="item-view">
<div class="item-view-header">
<a href=story.url target="_blank">
<h1>${story.title}</h1>
</a>
<p class="meta">
${story.points} points | by
<a href=`users/${story.user}`>${story.user}</a>
${story.time_ago} ago
</p>
</div>
<div class="item-view-comments">
<p class="item-view-comments-header">
${story.comments_count
? story.comments_count + " comments"
: "No comments yet."}
</p>
<ul class="comment-children">
<for|comment| of=story.comments>
<comment comment=comment />
</for>
</ul>
</div>
</div>
</@then>
</await>
</app-layout>
Again we have a nested component for the comments very similar to how we had for the stories that the compiler automatically detects and imports.
So far this has been mostly an exercise in templating and I've been able to cut and paste basically HTML. The comment component is the first one where we see some local state.
static function pluralize(n) {
return n + (n === 1 ? " reply" : " replies");
}
class {
onCreate() {
this.state = {open: true};
}
toggleOpen() {
this.state.open = !this.state.open;
}
}
<li class="comment">
<div class="by">
<a href=`users/${input.comment.user}`>${input.comment.user}</a>
${input.comment.time_ago} ago
</div>
<div class="text">$!{input.comment.content}</div>
<if(input.comment.comments.length)>
<div class=`toggle ${state.open ? "open" : ""}`>
<a onclick("toggleOpen")>
${state.open
? "[-]"
: "[+] " + pluralize(input.comment.comments.length) + " collapsed"}
</a>
</div>
<if(state.open)>
<ul class="comment-children">
<for|comment| of=input.comment.comments>
<comment comment=comment/>
</for>
</ul>
</if>
</if>
</li>
We define a class with onCreate
and a toggleOpen
event handler. We add our event handler to an anchor on our comment to toggle its visibility when clicked. It's a simple example but gives the page a little interactivity and convenience.
Adding the users page is the same thing. And in the end, our demo is 7 files. 3 pages, 3 components, and 1 service library. You can view the full source here, https://github.com/ryansolid/marko-hackernews.
Thoughts
I took this repo and deployed it to Heroku in a matter of minutes and the final demo can be found: https://marko-hackernews.herokuapp.com/
This was the easiest HackerNews port I've ever done. I'd never used most of these Marko features before and I had this all done in about an hour. It might not be a SPA so I do occasionally notice a bit of a content flicker on navigation but the pages load fast even as I simulate the slowest networks. Dropping the client-side routing for simple <a>
tags and having the power of <await>
made things so easy.
Possibly the coolest thing about this was, save for adding node-fetch
for the server, I wasn't really thinking of client vs server. I just wrote my app. And what Marko did with it is kinda cool.
I encourage you to look at the network tab in your browser debugger. You can see the pages load fast and the content stream in as it finishes, but there is no JavaScript sent to the page. I have all the non-blocking async loading characteristics I'm used to fetching on the client with something like Suspense, but 0kb of JavaScript.
Technically, there is some JavaScript inlined as it renders to swap in the content but this not something the end user needs to wait on. In fact, except for the hiding of comments on the story page, the Marko runtime isn't even sent to the browser. Pages progressively streamed in as soon as content was available with no need for any Hydration on most pages.
The crazy part was I wasn't even thinking about this during development. I just wrote my app the way I approach any client-side app and this was automatically handled for me. I wasn't thinking this is client-side, how do I make this work in SSR. I wasn't thinking this is server-side how I could incorporate a JS library like Stimulus or Alpine on my server-rendered code. I just wrote markup.
I had to sit for a moment to have this sink in. I've never experienced anything like this. It wasn't only absurdly easy to develop it felt completely natural that I forgot the browser and the server were different things and was only awoken to that fact when I realized there was no JavaScript being sent to the browser on most pages.
Conclusion
Marko is a marvel. Honestly, this experience changed my perspective greatly. I understood personally this sort of thing hasn't been my target as I've been making highly interactive apps, social media, etc.. If I was making eCommerce, blogs, or content sites this is a no-brainer. The experience is so simple, and the page-render performance is unparalleled.
SPAs definitely have their benefits but there are a class of sites that don't need them. Compared to most tools that slant either to the browser and the server, Marko is the first I've used where it wasn't only natural but I wasn't preoccupied with it.
The fact that it uses performance techniques for server rendering that outshine anything else on the JavaScript framework side of things was just a bonus. Although it became clear how that is the key to why Marko works here where others would have failed.
We will see more of this in other frameworks soon. Of that I'm sure. It's too good to be overlooked any longer (even if we've been sleeping on it since 2013).
Posted on March 10, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.