So if you have written a few articles on Dev.to, you may want to have your own site with your articles. I had previously just copied and pasted the markdown, but I recently discovered that there is a simple public API for Dev.to. So this article is about how I used the API to leverage Dev.to as a CMS for my personal site.
I am using this for my site NewFinds.com, you can find the full source here on GitHub
Getting started
There are a ton of great articles, and the docs for Next.js are fantastic. So I am not going to go too deep into the usage of Next.js, but I will touch on a few things that are relevant to static site generation from dynamic content.
The API endpoints we will be using for our data.
There are only three endpoints that we need to list posts for a user, post details (which contains the markdown), and the comments for a post. They are as follows.
-
The list of Posts:
https://dev.to/api/articles?username=${username}&page=1&per_page=100
-
The comments for a Post:
https://dev.to/api/comments?a_id=${postId}
-
The post details:
https://dev.to/api/articles/${username}/${slug}
I am using Typescript, which I find really useful when dealing with APIs. The types for the responses from the API are as such.
// lib/types.ts
export type User = {
name: string;
username: string;
twitter_username: string;
github_username: string;
user_id: number;
website_url: string;
profile_image: string;
profile_image_90: string;
};
export type Post = {
type_of: string;
id: number;
title: string;
description: string;
readable_publish_date: string;
slug: string;
path: string;
url: string;
comments_count: number;
public_reactions_count: number;
positive_reactions_count: number;
collection_id: number | null;
published_timestamp: string;
language: string;
subforem_id: number | null;
cover_image: string | null;
social_image: string;
canonical_url: string;
created_at: string;
edited_at: null;
crossposted_at: string;
published_at: string;
last_comment_at: string;
reading_time_minutes: number;
tag_list: string[];
tags: string;
user: User;
};
export type PostDetails = {
body_html: string;
body_markdown: string;
} & Post;
export type Comment = {
type_of: string;
id_code: string;
created_at: string;
body_html: string;
user: User;
children: Comment[];
};
The only difference between Post
and PostDetails
is body_html
and body_markdown
. The body_markdown
is what we will use for rendering our post.
Getting setup to statically render the site
The great thing about Next.js is that it can be whatever you need. An API with a database, a frontend SPA, an entirely static site, a static site built from dynamic content. This last one is our use case.
In order to let Next.js pre-render everything on build, (generate html files for every page) we have to setup our next.config.ts
file. The output = "export"
tells Next.js that we want to pre-render everything.
// next.config.ts
const nextConfig: NextConfig = {
output: "export",
//...
}
So now, when we run npm run build
our site will be pre built to a folder called out
, which we can host anywhere statically, its just HTML files and client side Javascript now.
Calling the API
Ill put our API calls into a separate file where we will make our API calls. I pulled this out, so that it can be shared with a few of our pages/components.
// app/lib/api.ts
import { Post, PostDetails, Comment } from "./types";
import { username } from "./consts";
export async function getAllPosts(): Promise<Post[]> {
const res: Response = await fetch(
`https://dev.to/api/articles?username=${username}&page=1&per_page=100`,
);
return await res.json();
}
export async function getPostBySlug(slug: string): Promise<PostDetails> {
const res: Response = await fetch(
`https://dev.to/api/articles/${username}/${slug}`,
);
return res.json();
}
export async function getComments(postId: number): Promise<Comment[]> {
const res: Response = await fetch(
`https://dev.to/api/comments?a_id=${postId}`,
);
return res.json();
}
The dynamic article page app/articles/[slug]/page.tsx
Ok so now that we have the ability to load data from dev.to, lets use it to create all the static pages generated from all our articles that come on the API.
Next.js uses the file and folder name to generate params, and determine if a page is dynamic. For more info: dynamic routes
// app/articles/[slug]page.tsx
import type { Post, PostDetails } from "@/app/lib/types";
import type { Metadata } from "next";
import type { JSX } from "react";
import Markdown from "react-markdown";
import rehypeHighlight from "rehype-highlight";
import { getAllPosts, getComments, getPostBySlug } from "@/app/lib/api";
import Comments from "@/app/components/comments";
type Params = {
slug: string;
};
type DynamicArgs = {
params: Promise<Params>;
};
export async function generateStaticParams(): Promise<Params[]> {
const posts: Post[] = await getAllPosts();
return posts.map((post: Post) => ({ slug: post.slug }));
}
export async function generateMetadata({ params }: DynamicArgs): Promise<Metadata> {
const { slug } = await params;
const post: PostDetails = await getPostBySlug(slug);
return {
title: post.title,
description: post.description,
};
}
export default async function Page({ params }: DynamicArgs): Promise<JSX.Element> {
const { slug } = await params;
const post: PostDetails = await getPostBySlug(slug);
const comments = await getComments(post.id);
return (
<>
<article className="md">
<h1>{post.title}</h1>
<Markdown rehypePlugins={[rehypeHighlight]}>
{post.body_markdown}
</Markdown>
</article>
<footer>
<div className="comments md">
<h2>Comments ({comments.length})</h2>
<Comments comments={comments} />
</div>
</footer>
</>
);
}
Generating the Static Params
The generateStaticParams
function tells Next.js how many pages to generate, and supplies the params, (in our case the slug) to the routes.
It is an async function, we load all the posts from our api, and then return an array of Objects, with a key of slug
in each.
export async function generateStaticParams(): Promise<Params[]> {
const posts: Post[] = await getAllPosts();
return posts.map((post: Post) => ({ slug: post.slug }));
}
Generating the Metadata
Similar to above, generateMetadata
function tells Next.js what metadata is for each page. We load the post for the page by slug using our API, and then return title
, and description
which Next.js will use to populate our <head>
on the page.
export async function generateMetadata({ params }: DynamicArgs): Promise<Metadata> {
const { slug } = await params;
const post: PostDetails = await getPostBySlug(slug);
return {
title: post.title,
description: post.description,
};
}
Rendering the Markdown to HTML
First we load react-markdown
and rehype-highlight
plugin which we will use to syntax highlight code examples in our markdown.
I think being able to render the syntax highlighting (at compile time), rather than on the client at run-time is a pretty neat benefit of pre-rendering our pages.
import Markdown from "react-markdown";
import rehypeHighlight from "rehype-highlight";
<Markdown rehypePlugins={[rehypeHighlight]}>
{post.body_markdown}
</Markdown>
Rendering our comments
I chose to render the comments, and its pretty simple with the data structure that is returned on the API. We simply render the <Comments>
component and pass in the comments for our article that we loaded.
// app/articles/[slug]/page.tsx
<h2>Comments ({comments.length})</h2>
<Comments comments={comments} />
Now if you remember the schema for comments, it supports children. Like responses to a comment and so forth. In order to support that, we can simply recursivly render the <Comments>
component, passing children as the comments prop.
I am stripping the HTML here, I assume Dev.to sanitizes user input, but its always a good idea to be careful with displaying user supplied content.
// app/components/comments.tsx
import type { JSX } from "react";
import type { Comment } from "@/app/lib/types";
import { stripHtml } from "string-strip-html";
import Image from "next/image";
type Props = {
comments: Comment[];
};
export default function Comments({ comments }: Props): JSX.Element {
return (
<ul>
{comments.map((comment) => (
<li key={comment.id_code}>
<h4>
<Image
src={comment.user.profile_image_90}
alt={comment.user.name}
width={48}
height={48}
/>
{comment.user.name}
</h4>
<p>{stripHtml(comment.body_html).result}</p>
{comment.children.length ? (
<Comments comments={comment.children} />
) : null}
</li>
))}
</ul>
);
}
Updating the site
Since the site is completely static, you will need to npm run build
the site each time you want to pull down more article, or comments ect. But if you are using Github pages like I am, it is as simple as running build and then pushing your changes.
Top comments (0)