Building a page feed component for your blog in Makeswift

July 24, 2024

Files search UI in Makeswift

Page feeds are a useful component to engage your site’s visitors with previews of relevant content that matches their interests. This guide will walk you through building a basic page feed component for a blog site in Makeswift that you can easily drag-and-drop into any of your Makeswift pages.



Setting up your host



To start, we’re going to need a custom Makeswift host running version `0.19.1` (or later) of the runtime. If you already have a custom host integrated with Makeswift and are running the correct version of the runtime, feel free to skip this step.



The easiest way to set up a custom host is with our CLI:

npx makeswift@latest init

Follow the steps in the CLI to create a new Next.js app integrated with Makeswift. For more information on getting started with this, check out our Quickstart documentation.



Creating the content



We can now start creating pages in the Makeswift builder. For this example, we’ll create a sample engineering blog site with the following pages:



- A home page - this will serve as the entry point to our blog and eventually display our page feed component. We’ll set the path for this page as /home



- Our blog posts - these pages will hold our actual blog content. Each of these pages should have a path starting with /blog/ so that they can be retrieved by our feed later. In the page sidebar, fill out the title, description, and social image (along with other useful metadata) for each blog post.







Creating a custom API route



In v0.19.0 of the runtime, we shipped a new getPages method on the Makeswift client that can retrieve detailed information on pages you’ve created in Makeswift. However, the client is not intended for use directly in a browser context, so we’re going to create an API route in our host that will return the pages retrieved from getPages.



In our custom host site, let’s create an API route under app/api/blog-feed/route.ts :



import { NextRequest } from 'next/server'

import { client } from '@/lib/makeswift/client'

export async function GET(req: NextRequest) {
  const searchParams = req.nextUrl.searchParams
  const limit = Number(searchParams.get('limit') ?? 4)
  const after = searchParams.get('after') ?? undefined

  const pagesResult = await client.getPages({
    limit,
    after,
    pathPrefix: '/blog/',
    sortBy: 'createdAt',
    sortDirection: 'desc'
  })

  return Response.json(pagesResult)
}



We’ve kept the interface for our custom endpoint very limited: the only query parameters accepted by this endpoint are for pagination, after and limit. For the remaining getPages options, we pass values to only retrieve Makeswift pages that have a path starting with /blog/, sorted by newest published pages first.





Creating the Feed Component



We can now start creating our page feed component. Our page feed component will show a list of page card links, along with a “Load More” button that will retrieve more pages from our endpoint. We’re going to use SWR’s infinite pagination to fetch our pages, but in your own implementation, feel free to use any data fetching tools you’d like.



Let’s start by installing swr in our host site:



npm i swr



Now, let’s create the boilerplate for our PageFeed component that will handle the retrieval of the pages:



// components/PageFeed/PageFeed.tsx

'use client'

import { Ref, forwardRef } from 'react'

import { MakeswiftPage } from '@makeswift/runtime/dist/types/next'
import clsx from 'clsx'
import useSWRInfinite from 'swr/infinite'

type BlogFeedResult = {
  data: MakeswiftPage[]
  hasMore: boolean
}

async function fetcher(url: string): Promise<BlogFeedResult> {
  const res = await fetch(url)
  return await res.json()
}

function getKeyFactory(limit: number): string | null {
  return (pageIndex: number, previousPageData: BlogFeedResult | null) => {
    if (previousPageData != null && !previousPageData.hasMore) return null
    const params = new URLSearchParams()
    params.set('limit', limit.toString())
    const lastPageId = previousPageData?.data.at(-1)?.id
    if (pageIndex > 0 && lastPageId) params.set('after', lastPageId)

    return `/api/blog-feed?${params.toString()}`
  }
}

export const PageFeed = forwardRef(function PageFeed(
  { className, pagesPerLoad = 5, showLoadMore = true }: Props,
  ref: Ref<HTMLDivElement>
) {
  const getKey = getKeyFactory(pagesPerLoad)
  const { data, size, setSize, isLoading, isValidating } = useSWRInfinite(getKey, fetcher)

  const pages = data?.flatMap(page => page.data) ?? []
  const hasMorePages = data?.at(-1)?.hasMore ?? true
  const loading = isLoading || isValidating

  return (
    <>{/* TODO */}</>
  )
})

export default PageFeed



Note that we dynamically create a getKey function for retrieving the SWR key based on the `pagesPerLoad` prop. We’ve made pagesPerLoad a prop so we can control this value from the Makeswift builder.



The data result from useSWRInfinite is an array of API responses, which is why we need to flatMap the retrieved pages into a single array. This is why we also evaluate hasMorePages based on the data contained in the last API response.



Now, let’s write some markup to render the retrieved pages, some skeleton loaders, and a “Load More” button that will manually retrieve more pages when clicked:



'use client'

import Image from 'next/image'
import Link from 'next/link'
import { Ref, forwardRef } from 'react'

import { MakeswiftPage } from '@makeswift/runtime/dist/types/next'
import clsx from 'clsx'
import useSWRInfinite from 'swr/infinite'

type BlogFeedResult = {
  data: MakeswiftPage[]
  hasMore: boolean
}

async function fetcher(url: string): Promise<BlogFeedResult> {
  const res = await fetch(url)
  return await res.json()
}

function getKeyFactory(limit: number): string | null {
  return (pageIndex: number, previousPageData: BlogFeedResult | null) => {
    if (previousPageData != null && !previousPageData.hasMore) return null
    const params = new URLSearchParams()
    params.set('limit', limit.toString())
    const lastPageId = previousPageData?.data.at(-1)?.id
    if (pageIndex > 0 && lastPageId) params.set('after', lastPageId)

    return `/api/blog-feed?${params.toString()}`
  }
}

type Props = {
  className?: string
  pagesPerLoad?: number
  showLoadMore?: boolean
}

export const PageFeed = forwardRef(function PageFeed(
  { className, pagesPerLoad = 4, showLoadMore = true }: Props,
  ref: Ref<HTMLDivElement>
) {
  const getKey = getKeyFactory(pagesPerLoad)
  const { data, size, setSize, isLoading, isValidating } = useSWRInfinite(getKey, fetcher)

  const pages = data?.flatMap(page => page.data) ?? []
  const hasMorePages = data?.at(-1)?.hasMore ?? true
  const loading = isLoading || isValidating

  return (
    <div ref={ref} className={clsx('flex flex-col items-center gap-3 py-6', className)}>
      {/* Skeleton loaders */}
      {pages.length === 0 &&
        loading &&
        Array.from(Array(pagesPerLoad)).map((_, i) => (
          <div
            key={i}
            className={clsx(
              'h-[172px] w-full max-w-4xl animate-pulse rounded-md',
              'bg-gradient-to-br from-slate-200 via-slate-100 to-slate-200'
            )}
          />
        ))}
      
      {pages.map(page => {
        return (
          <Link
            href={page.path}
            key={page.id}
            className={clsx(
              'flex h-[172px] max-w-4xl cursor-pointer rounded-md border border-gray-200 bg-white shadow-sm',
              'transition duration-200 hover:scale-[1.02] hover:shadow-md'
            )}
          >
            {page.socialImageUrl && (
              <Image
                src={page.socialImageUrl}
                width={148}
                height={148}
                alt=""
                priority
                className="w-auto rounded-l-md object-cover"
              />
            )}
            <div className="border-l border-gray-200 p-4">
              <h2 className="mb-2 text-xl font-bold">{page.title}</h2>
              <p className="mb-5 text-gray-600">{page.description}</p>
              <p className="text-blue-500 hover:underline">Read more</p>
            </div>
          </Link>
        )
      })}

      {hasMorePages && showLoadMore && (
        <button
          className={clsx(
            'mt-5 rounded-md bg-violet-500 px-4 py-3 text-white',
            'transition duration-200 hover:bg-violet-600',
            'disabled:cursor-not-allowed disabled:bg-violet-400'
          )}
          onClick={() => setSize(size + 1)}
          disabled={loading}
        >
          {loading ? 'Loading...' : 'Load more'}
        </button>
      )}
    </div>
  )
})

export default PageFeed



All our “Load More” button has to do is increase the number of paginated requests to make, and SWR will handle the rest! Note that we’ve added a className prop to add styles to the outermost div of the page feed, and a showLoadMore prop to selectively hide the “Load More” button. We may hide the button in case we’re using our page feed at the bottom of an existing blog post, and don’t want the component to appear too long.





Registering the Component in Makeswift



Now, all we have to do is register our component in Makeswift. Create a new components/PageFeed/PageFeed.makeswift.ts file, and use the Checkbox, Number, and Style prop to match the prop interface of the component:



import { lazy } from 'react'

import { Checkbox, Number, Style } from '@makeswift/runtime/controls'

import { runtime } from '@/lib/makeswift/runtime'

runtime.registerComponent(
  lazy(() => import('./PageFeed')),
  {
    type: 'PageFeed',
    label: 'Custom / Page Feed',
    props: {
      className: Style(),
      pagesPerLoad: Number({ label: 'Pages per load', defaultValue: 4, max: 20 }),
      showLoadMore: Checkbox({ label: 'Show Load More', defaultValue: true }),
    },
  }
)



Now, let’s add this component registration to our Makeswift imports, under lib/makeswift/component.ts:



 import "@/components/Accordions/Accordions.makeswift";
 import "@/components/Marquee/Marquee.makeswift";
+import "@/components/PageFeed/PageFeed.makeswift";
 import "@/components/Tabs/Tabs.makeswift";



With our host running, we can now test our component in the Makeswift builder:





And that’s it!



We can now drag and drop this component into any of our other pages to display a feed of the blog pages, and we can use the panel on the right to control the prop values visually.



The client getPages method includes many other options and returns page data not demonstrated in this post.



To see full details and more examples for getPages, check our method documentation.