Introduction
This tutorial will show how to create a customizable blog for developers to write about technical topics and provide coding solutions. This blog will have these features:
Code highlighting and formatting with a copy button
Dark and light modes
Responsive
Display of github projects
Add tags and categories to posts
Search for specific topics in blog
Sitemap for SEO
Github repository: https://github.com/donnabrown77/developer-blog
Prerequisites
JavaScript, React, Vscode. Knowledge of TypeScript, Next JS, Tailwind CSS is helpful.
Installation
npx create-next-app@latest --ts
Install the following npm packages:
npm install @next/mdx contentlayer date-fns encoding graphql graphql-request next-contentlayer next-themes rehype-pretty-code remark-gfm shiki shiki-themes unist-utils-visit
Package descriptions:
@next/mdx - Sources data from local files, allowing you to create pages with an .mdx extension, directly in your /pages or /app directory.
contentlayer - A content SDK that validates and transforms your content into type-safe JSON data you can easily import into your application’s pages.
date-fns - Toolset for manipulating JavaScript dates in a browser and Node.js.
encoding - Is a simple wrapper around iconv-lite to convert strings from one encoding to another.
graphql - JavaScript reference implementation for GraphQL, a query language for apis.
graphql-request - Minimal GraphQL client supporting Node and browsers for scripts or simple apps
next-contentlayer - Plugin to tightly integrate Contentlayer into a Next.js
next-themes - An abstraction for themes in your Next.js app.
rehype-pretty-code - plugin powered by the Shiki syntax highlighter that provides beautiful code blocks for Markdown or MDX.
remark-gfm - plugin to support GFM (autolink literals, footnotes, strikethrough, tables, tasklists)
shiki - syntax highlighter
shiki-themes - themes for shiki
unist-utils-visit - utility to visit nodes in a tree
Create sample blog posts
The blog post is a text file using a .mdx extension. MDX allows you to use JSX in your markdown content. You can import components, such as interactive charts or alerts, and embed them within your content. Each .mdx file uses frontmatter. Frontmatter is placed at the top of the MDX file between sets of three hyphens (---). Each frontmatter field should be placed on its own line.
Here is a definition of frontmatter: https://kabartolo.github.io/chicago-docs-demo/docs/mdx-guide/writing/#frontmatter
Make a new directory called _posts in your root directory. I used the underscore as the first character for the directory name to make it a private folder in Next JS. Private folders are not considered by Next JS’s routing system. Here is a link to a explanation of private folders: https://nextjs.org/docs/app/building-your-application/routing/colocation#private-folders.
This is where the mdx files are located. I created five frontmatter categories: title, date, tags, and author. Tags describe the topic or topics for a blog post. A tag is used for searching a specific blog topic. Here are links to sample mdx files:
https://github.com/donnabrown77/developer-blog/tree/main/_posts
You can change these to whatever you need.
next.config.js, tsconfig.json, contentlayerconfig.ts
Create a file called next.config.js in your root directory.
/** @type {import('next').NextConfig} */
const nextConfig = {};
const { withContentlayer } = require("next-content layer");
module.exports = withContentlayer({
experimental: {
appDir: true,
// https://stackoverflow.com/questions/75571651/using-remark-and-rehype-plugins-with-nextjs-13/75571708#75571708
mdxRs: false,
},
});
Modify tsconfig.json to look like this:
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@/components/*": ["components/*"],
"@/data/*": ["_data/*"],
"contentlayer/generated": ["./.contentlayer/generated"],
}
Create the file contentlayer.config.ts in the root directory.
Contentlayer is a content SDK that validates and transforms your content into type-safe JSON data you can easily import into your application.
Add this code to that file:
import { defineDocumentType, makeSource } from "contentlayer/source-files";
const Post = defineDocumentType(() => ({
name: "Post",
filePathPattern: `**/*.mdx`,
contentType: "mdx",
fields: {
title: {
type: "string",
description: "The title of the post",
required: true,
},
date: {
type: "date",
description: "The date of the post",
required: true,
},
tags: {
type: "json",
description: "post topics",
required: true,
},
excerpt: {
type: "string",
description: "summary of post",
required: true,
},
author: {
type: "string",
description: "who wrote the post",
required: false,
},
},
computedFields: {
url: {
type: "string",
resolve: (doc) => `/posts/${doc._raw.flattenedPath}`,
},
},
}));
export default makeSource({
contentDirPath: "_posts",
documentTypes: [Post],
mdx: {},
});
Create a components directory.
In the components directory, create two files MagnifyingGlass.tsx and Postcard.tsx. MagnifyingGlass.tsx is the svg to draw a maginifying glass used for an input control. PostCard.tsx returns the jsx for a single post.
app/components/MagnifyingGlass.tsx
import React from "react";
/**
*
* @returns svg for search input control
*/
const MagnifyingGlass = () => {
return (
<svg
className='absolute right-3 top-3 h-5 w-5 text-gray-400 dark:text-gray-300'
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'></path>
</svg>
);
};
export default MagnifyingGlass;
app/components/PostCard.tsx
import React from "react";
import { Post } from "contentlayer/generated";
import Link from "next/link";
import { format, parseISO } from "date-fns";
/**
*
* @param post
* @returns jsx to display post
*/
const PostCard = (post: Post) => {
let tags = [...post.tags];
return (
<article className='mb-12'>
<div className='space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0'>
<dl>
<dd className='text-gray-500 dark:text-gray-400 text-base font-medium leading-6'>
<time dateTime={post.date} className='mr-2 '>
{format(parseISO(post.date), "LLLL d, yyyy")}
</time>
</dd>
</dl>
<span className='text-gray-500 dark:text-gray-400'>{post.author}</span>
<div className='space-y-5 xl:col-span-3'>
<div className='space-y-6'>
<div>
<h2 className='text-2xl font-bold leading-8 tracking-tight'>
<Link href={post.url}>{post.title}</Link>
</h2>
<div className='flex flex-wrap'>
{post.tags && (
<ul className='inline-flex'>
{tags.map((tag) => (
<li
key={post.date}
className='mr-3 uppercase block text-sm text-blue-800 dark:text-blue-400 font-medium'
>
{tag}
</li>
))}
</ul>
)}
</div>
</div>
<div className='text-gray-500 dark:text-gray-400 prose max-w-none'>
{post.excerpt}
</div>
</div>
<div className='text-base font-medium leading-6'>
<Link
href={post.url}
className=' text-blue-800 dark:text-blue-400 hover:text-blue-400 dark:hover:text-blue-200'
>
Read more →
</Link>
</div>
</div>
</div>
</article>
);
};
export default PostCard;
Changes to page.tsx in app directory.
Remove everything from page.tsx and replace it with this code. Note the use of “use client”; at the top of the file which makes it a client component instead of the default server component in Next JS 13. When a user types in characters in the input control, the text is compared to the tags field of the mdx file. If there are posts with any of those tags, the page is rerendered to show only posts containing those tags.
"use client";
import React, { useState } from "react";
import { allPosts } from "contentlayer/generated";
import { compareDesc } from "date-fns";
import PostCard from "@/components/PostCard";
import MagnifyingGlass from "@/components/MagnifyingGlass";
import "./globals.css";
export default function Home() {
// handleInput will contain an array of topics or nothing
// if there is something returned, look for a matching post
// then display matching posts
const [topic, setTopic] = useState<string>("");
// get text from input control, use the value to set the topic.
const handleInput = (event: React.ChangeEvent<HTMLInputElement>) => {
setTopic(event.currentTarget.value);
};
// given an array of tags, return if there is a matching topic
function findTags(t: string[]) {
for (const element of t) {
// Matching topics are not case sensitive
let str1: string = element.toString().toLowerCase();
let str2: string = topic.toString().toLowerCase();
if (str1 === str2) return true;
}
return false;
}
let posts = allPosts.sort((a, b) =>
compareDesc(new Date(a.date), new Date(b.date))
);
// test array of tags for matching topic
let filteredPosts = posts.filter((post) => findTags(post.tags));
// if there are a posts which match the topic, display only those posts
if (filteredPosts.length > 0) posts = filteredPosts;
return (
<div className='mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0'>
<div className='space-y-2 pt-6 pb-8 md:space-y-5'>
<h1 className='text-3xl mb-8'>Developer Blog</h1>
<div className='relative max-w-lg mb-5 dark:bg-transparent'>
<input
type='text'
aria-label='Search articles'
placeholder='Search articles'
value={topic}
onChange={handleInput}
size={100}
className='block w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-900 dark:bg-gray-800 dark:text-gray-100'
></input>
<MagnifyingGlass />
</div>
{posts.map((post, idx) => (
<div key={idx}>
<hr className='grey-200 h-1 mb-10'></hr>
<PostCard key={idx} {...post} />
</div>
))}
</div>
</div>
);
}
Try running npm run dev. Your screen should look like this:
Top comments (0)