DEV Community

Cover image for Back to Basics: Building a HackerNews Clone with Marko
Ryan Carniato
Ryan Carniato

Posted on • Updated on

Back to Basics: Building a HackerNews Clone with Marko

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>
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>}
/>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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).

Top comments (6)

Collapse
 
mwcampbell profile image
Matt Campbell

How hard would it be to modify the app so the server does the API request before rendering the page, rather than showing a loading indicator and filling in the content asynchronously? I prefer to just wait a little longer for the initial load to complete, so that the initially rendered page is already settled. But then, my perception is skewed, because most of the web development I've done has been targeted at blind people using screen readers (and I'm visually impaired, so I often use a screen reader myself). A screen reader typically starts reading sequentially through a page as soon as the page finishes loading. So for that use case, static is better. (And yes, client-side routing causes an accessibility problem.) But I wonder if most users prefer a page that returns immediately with a loading indicator, then fills in the content shortly after that.

Collapse
 
peerreynders profile image
peerreynders

There is a sample app (marko-progressive-rendering) that demonstrates the various rendering modes. It was written in 2016 (early Marko 3).

My attempt at an update to Marko 5 - the "Single-Chunk" rendering code is here.

Granted, given what is going on in the official @marko/express, a production situation will likely need a few more lines to transfer $global for full functionality.

Collapse
 
ryansolid profile image
Ryan Carniato • Edited

With a Marko project not using our basic starter(@marko/serve) like our Webpack-Express starter it is a very easy one line change. You just change the render entry point to:

const html = await template.render(input);
Enter fullscreen mode Exit fullscreen mode

However the basic project template I used in this demo is all preconfigured to work with streaming. You could make the <await> the topmost component on the page and remove the client-reorder and placeholder and it should wait to render anything and still benefit from streaming in the head of the document for quicker script loading, but that only works effectively in a simple case like this. And I'm just hacking it.

That being said getting started with a Webpack-Express project is just as easy using @marko /create but it doesn't have the built-in routing but it gives you full control of the configuration.

And this might be a little exaggerated but this is why there is a preference for this loading pattern: markojs.com/#streaming. This is more or less the difference Marko's streaming/progressive rendering makes.

Collapse
 
peerreynders profile image
peerreynders

Tip:
"First Response Remedy" when things aren't working as expected ... (like a recently added style.css file having no apparent effect other than recompilation)

$ rm -rf node_modules/.cache/webpack
Enter fullscreen mode Exit fullscreen mode
Collapse
 
johnpageagixis profile image
johnpage-agixis • Edited

Windows doesn't like colons in filenames, particularly since NTFS treats "file:name" as an alternate data stream called "data" of the file called "file".

Sure enough, I tried cloning your git repo on windows and it said

error: invalid path 'src/pages/:stories/index.marko'
Enter fullscreen mode Exit fullscreen mode

Is there an alternative syntax?

EDIT: I can't find any mention of automatic route detection in the docs.

EDIT: found it hidden in the cli README for the serve command github.com/marko-js/cli/blob/maste...

Collapse
 
ryansolid profile image
Ryan Carniato

Not currently. I believe someone reported this issue a little while back. We were talking about changing to something similar to Next.js routing paths. Ie using src/pages/[stories].