PayloadCMS + Sveltekit: How to add a new feature

One of my favorite things about Payload is how easy it is to add new functionality.

With any content management system (CMS), many of the new features a developer will be asked to build are a (hopefully) simple matter of getting a new data shape sending to the front end, and modifying the view layer/frontend to present that raw data in a human-friendly way.

The difficulty presented here varies depending on the CMS you’re working with:

  • Wordpress remains popular for publishing content, but as soon as your content needs become more complex, you’re looking at custom code or third-party plugins – there simply isn’t a clean out-of-the-box way to describe new data shapes
  • Although the learning curve can be steep, Drupal is amazing at enabling developers and site builders (i.e. folks who don’t really code, but know their way around Drupal’s robust and mature module ecosystem) to create custom data shapes meeting specific content needs, then querying and presenting that content
  • With headless-only content management systems (where content is served over APIs, and there is no out-of-the-box way to send full HTML pages in response to requests instead), the contract becomes more clear, since your view layer is a separate application entirely

Payload’s Blocks functionality is one of the first of its design choices that was speaking my language. It’s one of its most powerful features. Essentially, it allows you to define custom data shapes, and give your users the power to choose any combination of those shapes.

This is so ridiculously potent, particularly when you combine it with the Array field and other functionality. The Array field type on its own is the way to go for homogenous, repeatable data shapes (e.g. an image, title and blurb for an old-school slider on the corporate home page), but by using Blocks within an Array, we can let the content author choose which blocks they need to tell the story.

Every post on this site is one record in my Posts collection, each made up of a few of the usual fields a blog post needs (category, tags, date, etc.) along with one big content field that’s an Array of as many (or as few) Blocks as I need to compose my story.

I’ve already built a few of the shapes I knew I’d need:

  • RichText, containing one single Rich Text field for prose – for now, this is the only field, but I can easily add other fields in the future if I want to, say, add a checkbox to tell the front end to highlight/emphasize a given usage of this block
  • Code, containing a Select field for choosing a language, and a Code field for… well… the code
  • Media, which lets me upload and display images only (for now)
  • Markdown, which I added mainly for those simple text posts that don’t need a bunch of other things (I start most of my writing in markdown)

This already adds up to a a pretty expressive way to build pages, but I can add more options here as my storytelling needs require.

The spec: Links

Let’s add a third Block for showing an arbitrary number of links that are related to the post. But rather than simply adding raw links right into the post within a RichText Block, I want to break these out into their own Links collection. That way I can potentially add features that use these links (e.g. a timeline of news by category, showing the evolution of something like artificial intelligence over time).

This will be a Payload Array field containing one single subfield: a Relationship to my new Links collection. Each link should have Text fields for the actual link URL, title and text, along with a Date field where I can add the date the original work was published. So let’s start with a simple configuration for my shiny, new Links collection:

import { CollectionConfig } from 'payload/types'

const Links: CollectionConfig = {
  slug: 'links',
  admin: {
    useAsTitle: 'text',
  },
  access: {
    read: () => true,
  },
  fields: [
    {
      name: 'text',
      label: 'Link text',
      type: 'text',
      required: true
    },
    {
      name: 'url',
      label: 'Link URL',
      type: 'text',
      required: true
    },
    {
      name: 'title',
      label: 'Link title',
      type: 'text'
    },
    {
      name: 'originalPublishingDate',
      type: 'date',
      admin: {
        date: {
          minDate: new Date('1800')
        }
      },
      required: true,
    }
  ],
  timestamps: true,
}

export default Links

Nothing wild to see here if you’ve already looked at some Payload config – or, Typescript/Javascript, really – just a few essential text fields and a date field. Adding a separate Date field allows me to add a Link long after it was originally published, and still have it work well in whatever time-sensitive functionality I add later on the front end. Out of the box, the farthest back I could choose was rather brief, so I set the minDate to the year 1800. If I somehow end up needing to go back farther, I can always update this.

With my new Links collection all set, it’s time to create a definition for the Block, and add it to the existing Blocks in my Posts collection.

All I’m going to do here is make a Block that can be imported into a Blocks field, and it’ll itself have one solitary field: an Array, but instead of defining its own data shape here with a handful of its own fields, it’ll contain a single Relationship field pointing to the new Links collection, and will allow me to add as many references to Links as I’d like.

Clear as mud, right? It’s probably easier to just read some code.

import { Block } from "payload/types"
import { blockFields } from "../../fields/blockFields"

export const Links: Block = {
  slug: 'linksBlock',
  fields: [
    blockFields({
      name: 'linksBlockFields',
      fields: [
        {
          name: 'links',
          type: 'array',
          fields: [
            {
              name: 'linkRel',
              type: 'relationship',
              relationTo: 'links',
              // If I were building this with intent for other users to add content, I’d probably also set a `max` here to limit the amount of links that can be displayed
            }
          ],
          required: true
        },
      ],
    })
  ]
}

You may have noticed this ‘blockFields’ function I’m pulling in – I got this pattern from the Payload team’s open source code for their own website’s Payload instance. It’s a pretty straightforward function that wraps all your fields in a Group field, among other things.

For the final step on the backend, let’s add this Block to the Blocks field I’ve already got on my Posts collection.

import { Links } from '../blocks/Links'
// ... other imports here

const Posts: CollectionConfig = {
  slug: 'posts',
  fields: [
    {
      name: 'contentBlocks',
      type: 'blocks',
      blocks: [
        RichText,
        Media,
        Markdown,
        Code,
        Links,
      ]
    },
    // ... additional fields
  ]
}

With that step complete, we can now see our new Block in action.

And now for the frontend

I won’t get into the actual data fetch to Payload to get the content from Sveltekit, as that’s pretty well documented, and it looks a little different depending on which API you’re using: GraphQL or REST. But it’s probably worth mentioning that if you’re using GraphQL to query an Array of Blocks like this and are just getting started, you’ll want to be sure to read up on Union types. Basically, you need to specify which fields you want for each Block type.

Once you’ve got the data in hand, it’s just a matter of using it in a Svelte component.

For each content block in this array, I’m also making sure to ask GraphQL for the blockType. I can loop through the array of Block data, and render each one through my ContentBlock component. Svelte gives us nice, clean way to render different components dynamically at runtime.

<script>
  import { onMount } from 'svelte'

  import CodeBlock from './CodeBlock.svelte'
  import JsonDebug from './JsonDebug.svelte'
  import LinksBlock from './LinksBlock.svelte'
  import MarkdownBlock from './MarkdownBlock.svelte'
  import MediaBlock from './MediaBlock.svelte'
  import RichTextBlock from './RichTextBlock.svelte'
  import SvelvetBlock from './SvelvetBlock.svelte'

  import Row from '$lib/utils/Row.svelte'

  let calculatedBlockType = undefined
  
  export let props = undefined

  onMount(()=>{
    if(props.blockType){
      switch(props.blockType) {
        case 'code':
          calculatedBlockType = CodeBlock
          break
        case 'diagram':
          calculatedBlockType = SvelvetBlock
          break
        case 'richText':
          calculatedBlockType = RichTextBlock
          break
        case "linksBlock":
          calculatedBlockType = LinksBlock
          break
        case "markdown":
          calculatedBlockType = MarkdownBlock
          break
        case "mediaBlock":
          calculatedBlockType = MediaBlock
          break
        default:
          calculatedBlockType = JsonDebug
      }
    }
  })
</script>

<Row>
  <svelte:component this={ calculatedBlockType } { props } />
</Row>

You can see how I import all of the components I need to represent what essentially amounts to a stack of content blocks that come together to make the story. This component will render as many times as it needs to to get the job done, and if it encounters a blockType that it isn’t expecting (i.e., it’s not in my switch statement), it’ll just dump that data shape onto the page using a simple debug component I put together to stringify the JSON and drop it onto the page. This really nice when working on this stuff locally, and, my site being kinda nerd-oriented and all, I’m OK with that showing on my live site in the event that I manage to let that happen.

Now, for our final bit of frontend, let’s look at how we display these links in a Svelte component. I’ll omit the CSS, as I’m probably going to change that pretty heavily soon anyway.

<script>
  import Book from '../primitives/heroicons/Book.svelte'

  export let props = undefined
  let links // we don't need to declare this, as the line below will do that for us, but I like to include it for readability

  $: links = props.linksBlockFields.links ? props.linksBlockFields.links : []
</script>

{ #if links.length }
  <div class="links-block">
    <h2>
      <Book class="inline w-5 w-5 mr-1" />
      Related Reading
    </h2>
    <ul>
      { #each links as link }
      <li>
        <a title="{ link.linkRel.title }" href="{ link.linkRel.url }" data-published="{ link.linkRel?.originalPublishingDate }" target="_blank">{ link.linkRel.text }</a>
      </li>
      { /each }
    </ul>
  </div>
{ /if }

I import an icon I want to display alongside the title, export the generic ‘props’ variable that all of this component’s peers do, then take off of that what I need with the reactive statement on line 7.

That dollar sign JS Label syntax, which Svelte leverages as a way of telling the compiler a statement should be evaluated again when the values it depends on change. This lets me ensure that the component always has an array that is zero items or longer. I could show alternative UI if the array of links is empty, but for now I’ll just render nothing. This is an easy way to always have something in the field that Svelte can work with, even if the payload takes a while to download.

And that should do it! Notwithstanding questionable design choices, here’s our new feature in action:

©2024 Joe Castelli