How to Add a Changelog to Your Next.js Website?

Keeping your audience informed about the latest updates and changes to your software is crucial. One effective way to do this is by adding a changelog to your website. In this guide, we'll explore how to integrate a changelog into your Next.js website using Wisp, a content management system designed for modern applications. By the end of this post, you'll be able to create and display a dynamic changelog using Next.js and WISP.

Watch the video:

Step 1: Setting Up WISP Content Management System

Wisp is central to our changelog feature, as it allows us to define and manage custom content types. Here's how you can set it up:

  1. Sign in to Wisp and navigate to the "Content Manager."

  2. Click on "Manage Content Types" if this is your first content type, otherwise, click on the gear icon to manage your content type.

  3. Click on "Add New Content Type" to create a new content type.

  4. Create a new content type called "changelog."

  5. Add relevant fields such as title (text format) and content (text area).

Wisp will automatically generate an API endpoint for fetching changelog data, which we'll use in our Next.js application.

Step 2: Fetching the Changelog Data

Next.js offers a streamlined way to fetch and render data. In this example, we will use the JS SDK to fetch changelog entries from Wisp's API.

Here’s the code for the changelog route:

// in app/changelog/page.tsx

export const dynamic = "force-dynamic";
export const revalidate = 60; // 1 minute'

import {
  Timeline,
  TimelineContent,
  TimelineDot,
  TimelineHeading,
  TimelineItem,
  TimelineLine,
} from "@/components/ui/timeline";
import { format } from "date-fns";
import Markdown from "react-markdown";
import { wisp } from "../../wisp";

interface Changelog {
  title: string;
  changelog?: string;
}

export default async function Page() {
  const result = await wisp.getContents<Changelog>({
    contentTypeSlug: "changelog",
    limit: "all",
  });

  return (
    <div className="container max-w-6xl pb-24">
      <div className="m-auto mb-12 max-w-2xl">
        <h1 className="mb-4 text-center text-4xl font-bold">Changelog</h1>
        <p className="text-muted-foreground text-center text-lg">
          List of changes and updates made to Wisp CMS.
        </p>
      </div>
      <div className="m-auto max-w-2xl">
        <Timeline positions="left">
          {result.contents.map((change) => (
            <TimelineItem key={change.id}>
              <TimelineHeading side="right">
                {change.content.title}{" "}
                {change.publishedAt &&
                  `(${format(change.publishedAt, "dd/MM/yyyy")})`}
              </TimelineHeading>
              <TimelineDot status="done" className="border-primary" />
              <TimelineLine done />
              <TimelineContent side="right" className="prose w-full">
                <Markdown>{change.content.changelog}</Markdown>
              </TimelineContent>
            </TimelineItem>
          ))}
        </Timeline>
      </div>
    </div>
  );
}

If you have not set up the Wisp client, remember to fetch your blogId from the "Setup" tab and create the client:

// in app/wisp.ts
import { buildWispClient } from "@wisp-cms/client";

export const wisp = buildWispClient({
  blogId: process.env.NEXT_PUBLIC_BLOG_ID!, // setup the environment variable or replace it with the `blogId` string
});

Step 3: Create the UI with Custom Components

The timeline design is implemented using custom ShadCN component. These components provide a visual representation of each changelog entry.

Here's a breakdown of how the Timeline components are structured:

// in components/ui/timeline
import { CheckIcon, Cross1Icon } from "@radix-ui/react-icons";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import React from "react";

import { cn } from "@/lib/utils";

const timelineVariants = cva("grid", {
  variants: {
    positions: {
      left: "[&>li]:grid-cols-[0_min-content_1fr]",
      right: "[&>li]:grid-cols-[1fr_min-content]",
      center: "[&>li]:grid-cols-[1fr_min-content_1fr]",
    },
  },
  defaultVariants: {
    positions: "left",
  },
});

interface TimelineProps
  extends React.HTMLAttributes<HTMLUListElement>,
    VariantProps<typeof timelineVariants> {}

const Timeline = React.forwardRef<HTMLUListElement, TimelineProps>(
  ({ children, className, positions, ...props }, ref) => {
    return (
      <ul
        className={cn(timelineVariants({ positions }), className)}
        ref={ref}
        {...props}
      >
        {children}
      </ul>
    );
  },
);
Timeline.displayName = "Timeline";

const timelineItemVariants = cva("grid items-center gap-x-2", {
  variants: {
    status: {
      done: "text-primary",
      default: "text-muted-foreground",
    },
  },
  defaultVariants: {
    status: "default",
  },
});

interface TimelineItemProps
  extends React.HTMLAttributes<HTMLLIElement>,
    VariantProps<typeof timelineItemVariants> {}

const TimelineItem = React.forwardRef<HTMLLIElement, TimelineItemProps>(
  ({ className, status, ...props }, ref) => (
    <li
      className={cn(timelineItemVariants({ status }), className)}
      ref={ref}
      {...props}
    />
  ),
);
TimelineItem.displayName = "TimelineItem";

const timelineDotVariants = cva(
  "col-start-2 col-end-3 row-start-1 row-end-1 flex size-4 items-center justify-center rounded-full border border-current",
  {
    variants: {
      status: {
        default: "[&>*]:hidden",
        current:
          "[&>*:not(.radix-circle)]:hidden [&>.radix-circle]:bg-current [&>.radix-circle]:fill-current",
        done: "bg-primary [&>*:not(.radix-check)]:hidden [&>.radix-check]:text-background",
        error:
          "border-destructive bg-destructive [&>*:not(.radix-cross)]:hidden [&>.radix-cross]:text-background",
        custom: "[&>*:not(:nth-child(4))]:hidden [&>*:nth-child(4)]:block",
      },
    },
    defaultVariants: {
      status: "default",
    },
  },
);

interface TimelineDotProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof timelineDotVariants> {
  customIcon?: React.ReactNode;
}

const TimelineDot = React.forwardRef<HTMLDivElement, TimelineDotProps>(
  ({ className, status, customIcon, ...props }, ref) => (
    <div
      role="status"
      className={cn("timeline-dot", timelineDotVariants({ status }), className)}
      ref={ref}
      {...props}
    >
      <div className="radix-circle size-2.5 rounded-full" />
      <CheckIcon className="radix-check size-3" />
      <Cross1Icon className="radix-cross size-2.5" />
      {customIcon}
    </div>
  ),
);
TimelineDot.displayName = "TimelineDot";

const timelineContentVariants = cva(
  "row-start-2 row-end-2 pb-8 text-muted-foreground",
  {
    variants: {
      side: {
        right: "col-start-3 col-end-4 mr-auto text-left",
        left: "col-start-1 col-end-2 ml-auto text-right",
      },
    },
    defaultVariants: {
      side: "right",
    },
  },
);

interface TimelineConentProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof timelineContentVariants> {}

const TimelineContent = React.forwardRef<HTMLDivElement, TimelineConentProps>(
  ({ className, side, ...props }, ref) => (
    <div
      className={cn(timelineContentVariants({ side }), className)}
      ref={ref}
      {...props}
    />
  ),
);
TimelineContent.displayName = "TimelineContent";

const timelineHeadingVariants = cva(
  "row-start-1 row-end-1 line-clamp-1 max-w-full truncate",
  {
    variants: {
      side: {
        right: "col-start-3 col-end-4 mr-auto text-left",
        left: "col-start-1 col-end-2 ml-auto text-right",
      },
      variant: {
        primary: "text-base font-medium text-primary",
        secondary: "text-sm font-light text-muted-foreground",
      },
    },
    defaultVariants: {
      side: "right",
      variant: "primary",
    },
  },
);

interface TimelineHeadingProps
  extends React.HTMLAttributes<HTMLParagraphElement>,
    VariantProps<typeof timelineHeadingVariants> {}

const TimelineHeading = React.forwardRef<
  HTMLParagraphElement,
  TimelineHeadingProps
>(({ className, side, variant, ...props }, ref) => (
  <p
    role="heading"
    aria-level={variant === "primary" ? 2 : 3}
    className={cn(timelineHeadingVariants({ side, variant }), className)}
    ref={ref}
    {...props}
  />
));
TimelineHeading.displayName = "TimelineHeading";

interface TimelineLineProps extends React.HTMLAttributes<HTMLHRElement> {
  done?: boolean;
}

const TimelineLine = React.forwardRef<HTMLHRElement, TimelineLineProps>(
  ({ className, done = false, ...props }, ref) => {
    return (
      <hr
        aria-orientation="vertical"
        className={cn(
          "col-start-2 col-end-3 row-start-2 row-end-2 mx-auto flex h-full min-h-16 w-0.5 justify-center rounded-full",
          done ? "bg-primary" : "bg-muted",
          className,
        )}
        ref={ref}
        {...props}
      />
    );
  },
);
TimelineLine.displayName = "TimelineLine";

export {
  Timeline,
  TimelineContent,
  TimelineDot,
  TimelineHeading,
  TimelineItem,
  TimelineLine,
};

(Original Source for Timeline Component)

Step 4: Deploy and Test

After implementing the changelog feature, deploy your Next.js site to test the changelog display. Ensure the API requests all function as expected and the UI layouts are correctly rendered.

Conclusion

By following this guide you can efficiently implement a changelog system into your Next.js application using WISP CMS. This setup not only makes it simple to announce updates to your users, but it also offers a clean, organized presentation using custom UI components. Whether for software updates, product announcements, or internal tracking, a changelog keeps your users engaged and informed.

Raymond Yeh

Raymond Yeh

Published on 29 November 2024

Get engineers' time back from marketing!

Don't let managing a blog on your site get in the way of your core product.

Wisp empowers your marketing team to create and manage content on your website without consuming more engineering hours.

Get started in few lines of codes.

Choosing a CMS