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:
Sign in to Wisp and navigate to the "Content Manager."
Click on "Manage Content Types" if this is your first content type, otherwise, click on the gear icon to manage your content type.
Click on "Add New Content Type" to create a new content type.
Create a new content type called "changelog."
Add relevant fields such as
title
(text format) andcontent
(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.