tutorial // Feb 03, 2023

How to Handle SEO Metadata in Joystick

How to leverage Joystick's res.render() function to dynamically render SEO metadata for your app.

How to Handle SEO Metadata in Joystick

For this tutorial, we're going to use CheatCode's full-stack JavaScript framework, Joystick. Joystick brings together a front-end UI framework with a Node.js back-end for building apps.

To begin, we'll want to install Joystick via NPM. Make sure you're using Node.js 16+ before installing to ensure compatibility (give this tutorial a read first if you need to learn how to install Node.js or run multiple versions on your computer):

Terminal

npm i -g @joystick.js/cli

This will install Joystick globally on your computer. Once installed, next, let's create a fresh project:

Terminal

joystick create app

After a few seconds, you will see a message logged out to cd into your new project and run joystick start. Before you do this, we want to install one additional dependency @faker-js/faker:

Terminal

cd app && npm i @faker-js/faker

After this, go ahead and start up your app:

Terminal

joystick start

After this, your app should be running and we're ready to get started.

Adding a test page

Although our focus for this tutorial will be primarily on adding SEO metadata, to contextualize our work, we're going to quickly set up a new test page. This will just render some dummy HTML, but, give us something to attach our SEO metadata to in a way that's more in line with a real-world use case.

/ui/pages/post/index.js

import ui from '@joystick.js/ui';

const Post = ui.component({
  render: () => {
    return `
      <div class="post">
        <h1>This is a fake post</h1>
        <p>We just want to see something on screen so we can get to the HTML.</p>
      </div>
    `;
  },
});

export default Post;

Very simple. Here, we're defining a very basic Joystick component that does nothing more than render some dummy HTML. What we really care about for this tutorial takes place on the server, so let's jump there now.

Wiring up some fake test data

First, in order to show off how SEO metadata can be leveraged properly, we want to create a "blog post" in a database that we can retrieve when rendering our test page. The idea being that we want to simulate rendering the SEO metadata for a real blog post. To do it, we're going to use the special fixtures function that @joystick.js/node supports as an option on its .app() function.

/index.server.js

import node from "@joystick.js/node";
import { faker } from '@faker-js/faker';
import api from "./api";

node.app({
  api,
  fixtures: async () => {
    const existingPosts = await process.databases.mongodb.collection('posts').countDocuments();

    if (existingPosts === 0) {
      const title = faker.lorem.sentence(10);
      await process.databases.mongodb.collection('posts').insertOne({
        _id: node.id(),
        publishedAt: faker.date.past(),
        updatedAt: faker.date.past(),
        title,
        slug: 'test-post',
        seo: {
          description: faker.lorem.paragraph(),
          image: 'https://unsplash.it/1920/1080',
        },
        content: faker.lorem.paragraphs(5),
        category: 'reviews',
        tags: ['movies', 'action', 'classics']
      });
    }
  },
  routes: {
    "/": (req, res) => {
      res.render("ui/pages/index/index.js", {
        layout: "ui/layouts/app/index.js",
      });
    },
    "*": (req, res) => {
      res.render("ui/pages/error/index.js", {
        layout: "ui/layouts/app/index.js",
        props: {
          statusCode: 404,
        },
      });
    },
  },
});

Above, we have the file that's automatically loaded by Joystick's CLI when we run joystick start. This file is assumed to contain the code to start our Joystick app (more specifically, the HTTP server that Joystick starts behind the scenes using Express.js). To do that, we import the default export from @joystick.js/node which is an object with a few different properties and methods on it. Here, we import that as node and then call the .app() method to start up the server/app.

To it, we pass an object of properties which lets us define our api, routes, and a few other options. The one we care about here is fixtures() which is a generic function that Joystick calls after all of the databases for our app have been started and attached to our app.

Inside, we want to do two things:

  1. Check to see if we've already created a test post.
  2. If not, create the test post.

To do it, we're going to use MongoDB (the default database started when we create an app with joystick create app). More specifically, we start by first appending the async keyword to the function we're assigning to the fixtures property so that we can use the await keyword in front of our database calls (without this, JavaScript will throw a runtime error as we have to explicitly define the boundary within which we'll use an await keyword).

Here, we use the await keyword in front of our first database call to get a count of the existingPosts in our database: await process.databases.mongodb.collection('posts').countDocuments();. So it's clear, everything after the process.databases.mongodb part comes from the official MongoDB Node.js driver. Joystick does nothing more than connect that driver to our database and then make it accessible at process.databases.mongodb.

Here, we call to the posts collection, running the .countDocuments() method to get back an integer of how many posts we already have. If we have 0, we want to create a new post.

To create the post, we want to again call to the MongoDB driver at process.databases.mongodb—again accessing the posts collection—however this time, we want to call the .insertOne() method passing an object representing the test post to create.

To help us create the test post here, we've imported the faker library that we installed at the start of the tutorial (you don't have to use this—it's purely for convenience). The content here doesn't matter too much; our goal is just to simulate a blog post or other content that could benefit from utilizing SEO metadata. Of note, we've included a special seo object here with two things: a description and an image. These don't have to be nested here, we're just using it as an example. They could live "top level" with the other properties.

That's all we need for test data. Now, when our server starts up, if this is the first time our fixtures function is running, we should get a test post added to our database.

Wiring up the SEO metadata

Now for the important part. Remember that earlier, we created a test page at /ui/pages/post/index.js. Next, we want to wire that up to a route and set the SEO metadata that we want to be rendered in our HTML whenever that route is visited. Let's add the route first and then see how we add our metadata:

/index.server.js

import node from "@joystick.js/node";
import { faker } from '@faker-js/faker';
import api from "./api";

node.app({
  api,
  fixtures: async () => { ... },
  routes: {
    "/": (req, res) => {
      res.render("ui/pages/index/index.js", {
        layout: "ui/layouts/app/index.js",
      });
    },
    "/posts/:slug": async (req, res) => {
      const post = await process.databases.mongodb.collection('posts').findOne({
        slug: req?.params?.slug,
      });

      res.render("ui/pages/post/index.js", {});
    },
    "*": (req, res) => {
      res.render("ui/pages/error/index.js", {
        layout: "ui/layouts/app/index.js",
        props: {
          statusCode: 404,
        },
      });
    },
  },
});

Above, to the routes object passed to our options for node.app(), we've added a new route /posts/:slug. To define it, we specify the path for the route as a property on the routes object and assign it a "handler" function. Whenever our server gets a request to a URL matching this pattern (/posts followed by any text), this handler function will be called. Again, just like we saw earlier, we're going to be making a database call and leveraging await so we prefix our handler function with async.

First, we want to retrieve our test post from the database. To do it, we just run a call to .findOne() on the posts collection passing a query to match any post with a slug equal to the value of req?.params?.slug, or, the value in the position of:slug` in our route.

After this, to render our page, we use Joystick's res.render() method which takes the path of the page we want to render as the first argument and as the second argument, an object of options. Now, let's add our SEO metadata to that object and walk through how it works.

/index.server.js

import node from "@joystick.js/node";
import { faker } from '@faker-js/faker';
import api from "./api";

node.app({
  api,
  fixtures: async () => { ... },
  routes: {
    "/": (req, res) => {
      res.render("ui/pages/index/index.js", {
        layout: "ui/layouts/app/index.js",
      });
    },
    "/posts/:slug": async (req, res) => {
      const post = await process.databases.mongodb.collection('posts').findOne({
        slug: req?.params?.slug,
      });

      res.render("ui/pages/post/index.js", {
        head: {
          title: post?.title,
          tags: {
            meta: [
              { name: 'description', content: post?.seo?.description },
              { name: "twitter:card", content: "summary_large_image" },
              { name: "twitter:site", content: "@cheatcodetuts" },
              { name: "twitter:title", content: post?.title },
              { name: "twitter:description", content: post?.seo?.description },
              { name: "twitter:creator", content: "@cheatcodetuts" },
              { name: "twitter:image:src", content: post?.seo?.image },
              { name: "twitter:card", content: "summary_large_image" },
              { name: "og:title", content: post?.title },
              { name: "og:type", content: 'article' },
              { name: "og:url", content: `http://localhost:2600/posts/${post?.slug}` },
              { name: "og:image", content: post?.seo?.image },
              { name: "og:description", content: post?.seo?.description },
              { name: "og:site_name", content: "FakeBlog" },
              {
                name: "og:published_time",
                content: post?.publishedAt || new Date().toISOString(),
              },
              {
                name: "og:modified_time",
                content: post?.updatedAt || new Date().toISOString(),
              },
            ]
          },
          jsonld: {
            "@context": "https://schema.org/",
            "@type": "Article",
            headline: post?.title,
            author: {
              "@type": "Person",
              name: "Ryan Glover",
            },
            "publisher": {
              "@type": "Organization",
              "name": "CheatCode",
              "logo": {
                "@type": "ImageObject",
                "url": "https://cheatcode-assets.s3.amazonaws.com/logo-tm.png"
              }
            },
            datePublished: post?.publishedAt,
            description: post?.seo?.description,
            image: post?.seo?.image,
          },
        },
      });
    },
    "*": (req, res) => {
      res.render("ui/pages/error/index.js", {
        layout: "ui/layouts/app/index.js",
        props: {
          statusCode: 404,
        },
      });
    },
  },
});

This may seem a little overwhelming, but it's technically a lot of repetition. To add SEO metadata (or technically, any metadata, links, or script tags to the <head></head> tag of our page), we want to leverage the head option passed via the options object to res.render().

To it, we can pass a few things. For our example, we're setting three properties:

  1. title which contains the title of the post we're trying to render.
  2. tags which contains any HTML tags we want to render, with the ones we care about being added via the meta array.
  3. jsonld which contains an object specifying the JSON-LD metadata used by Google.

The title is fairly straightforward: here we're just adding the title of our post. tags is a bit more involved...here, we're taking an open-ended approach as different crawlers and search engines look for different tags. To keep things simple, here, we're defining some meta tags that Twitter (they call their tags ["Card Tags"](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup) and Facebook (ogis theirs and stands for ["Open Graph"](https://ogp.me/)) will exclusively target along with a genericdescription` at the top of the array.

If we look close, we can see that for the content property on some of these tags, we're passing the appropriate field from our post. When our app renders, what we expect is for each tag to be rendered automatically with the content populated using our dynamic value (e.g., <meta name="og:title" content="<the title of the post here>" />).

If we jump down to the jsonld object, the exact same idea is playing out. However here, we need to follow a special structure defined via the JSON-LD spec and leverages metadata specification via the schema.org spec.

That's it! While this covers a small subset of what's possible, you can add any metadata that's supported as a meta tag to the tags.meta array or any schema-supported JSON-LD markup. What's great about that is you're not limited to just this example—any new or alternative service that supports these basic conventions will work.

Wrapping up

In this tutorial, we learned how to define SEO metadata using the head property on Joystick's res.render() method. First, we rigged up a test page to render via our router and then, learned how to create some test data using the fixtures() function passed to our node.app() instance. Finally, we learned how the head option works on the res.render() function, learning how to pass the appropriate meta tags for different search engines and social networks we want to crawl our site for SEO data.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode