Building my new website with Next.js, TailwindCSS, and Vercel
For the first time in a decade, I finally updated my personal website. This is something Iâve wanted to do for a long time, but I never gotten around to it until now.
The site is built with Next.js and TailwindCSS, and itâs hosted on Vercel. You can view the source code on GitHub.
Table of Contents
Goals
I set some goals when beginning the redesign process:
- Build something modern and fast, e.g. React or Next.js
- Preserve the ~100 blog posts I had on my old WordPress site
- Consolidate my newer blog posts into a single place
- Share the writing Iâve been doing on other side project sites
- Highlight the side projects I work on
Importing WordPress posts into Next.js
Sigh.
I started my old WordPress site in 2008 when I was a freshman in college. I literally bought a cheap desktop computer from Walmart, set it up in my dorm room because it had high-speed internet, asked the IT department for a static IP address, and hosted a WordPress blog from my computer.
Thereâs a lot of nostalgia there. Iâve written lots of posts over the past twelve years, and WordPress has served me well. I didnât want to give up the ghost quite yet and move to a completely-static site, so I made the decision to preserve the site and import the posts using the WordPress REST API.
Luckily, Nextâs static-site generation (SSG) APIs made this a breeze!
I wanted to keep the same URL structure for SEO purposes:
/2018/06/i-created-an-educational-site-called-full-stack-fundamentals
This can be tricky, since a traditional dynamic route might look more like:
/posts/[slug]
However, Next provides catch-all routes to make this possible. First, I added a new page named pages/[...slug].js
. This meant an incoming request with the above URL will be parsed by Next into an array of params:
['2018', '06', 'i-created-an-educational-site-called-full-stack-fundamentals'];
In order to fetch the requested post, I leveraged the getStaticProps
method. WordPress allows you to query by slug
alone without the leading date prefix. To do this, I grabbed the last item in the params
array to send off to the JSON endpoint:
// pages/[...slug].js
export async function getStaticProps({params}) {
const res = await fetch(
`https://archive.jplhomer.org/wp-json/wp/v2/posts?slug=${params.slug[2]}`,
);
const posts = await res.json();
const post = posts[0];
return {
props: {
post,
},
};
}
Additionally, I needed to define a getStaticPaths
method to tell Next how to pre-render the incoming static pages. In order to save some time during the build process, I chose to build only the first ten posts and then allowed the remainder to be generated at runtime;
// pages/[...slug].js
export async function getStaticPaths() {
const {posts} = await getArchivePosts(10);
const paths = posts.map((post) => ({
params: {
slug: post.nextSlug,
},
}));
return {
paths,
fallback: true,
};
}
Finally, I wanted to support paginated archives for my posts. To do that, I added a new dynamic route pages/archives/[page].js
:
// pages/archives/[pages].js
export async function getStaticPaths() {
const paths = [1, 2, 3].map((page) => ({params: {page: String(page)}}));
return {
paths,
fallback: true,
};
}
export async function getStaticProps({params}) {
const {page} = params;
const {posts, total} = await getArchivePosts(
PER_PAGE,
PER_PAGE * (Number(page) - 1),
);
return {
props: {
page: Number(page),
posts,
total,
},
};
}
This creates three initial archive pages and allows the rest to be generated at runtime. I also pass the page
and total
props down to the component so I can render pagination UI.
Blogging with MDX
Although Iâm still importing my old WordPress posts via a JSON API, I want to write all future posts with MDX. Itâs like Markdown, only super-powered with React capabilities.
Useing this exactly how I wanted proved to be a bit of a challenge.
On one hand, you have the official Next.js MDX plugin. This is great if you want to simply import partials of Markdown:
import Content from './content.mdx';
export default function Page() {
return <Content />;
}
The downside with this approach is that itâs difficult to combine the power of getStaticProps
with the MDX files, because the MDX compilation happens at the Webpack level.
You can also enable top-level pages like pages/content.mdx
if you want. Unfortunately, youâre stuck with whatever global layout your app is providing, making it difficult to add custom layout features or pull in frontmatter metadata like title
.
Also: I had no idea MDX didnât support frontmatter out of the box. Kind of a disappointed that I needed to pull in a separate package for this.
Apparently next-mdx-enhanced is a nice alternative, but I just couldnât get past a weird compile error đ.
So I rolled with my own strategy!
For âdynamicâ content like blog posts, I followed the âDo It Yourselfâ guide on the MDX website and made it a simple renderToMdx
server-side module on my site.
Then, I used the fs
package to iterate through the /posts
directory in my project to parse the Markdown and MDX files. I used the front-matter
package to strip out the frontmatter and provide it in the output.
import {promises as fs} from 'fs';
import path from 'path';
import frontmatter from 'front-matter';
import {renderMdx} from './mdx';
const POSTS_PATH = path.resolve(process.cwd(), 'posts');
export async function getPost(postPath, withBody = false) {
const content = await fs.readFile(
path.resolve(POSTS_PATH, postPath),
'utf-8',
);
const {attributes, body} = frontmatter(content);
const bodyOutput = withBody ? await renderMdx(body) : '';
// Next.js complains if a legit Date object gets passed through
attributes.date = attributes.date.toString();
return {
path: postPath,
body: bodyOutput,
nextPath: postPath.replace(/.mdx?/, ''),
...attributes,
};
}
export async function getPosts() {
const paths = await fs.readdir(POSTS_PATH);
const posts = await Promise.all(
paths.map(async (path) => {
return await getPost(path);
}),
);
return posts.sort((a, b) => new Date(b.date) - new Date(a.date));
}
export async function getFilenameFromSlug(slug) {
const paths = await fs.readdir(POSTS_PATH);
return paths.find((p) => new RegExp(slug).test(p));
}
You can view my mdx.js
file to see how renderMdx
was implemented.
::: warning Heads up!
Since this method is totally separate from the Webpack build process, custom imports are not
supported in the individual MDX files. They need to be passed into the MDXProvider
âs components object to
be accessible. This is kind of gross, and I try to work around it as much as possible.
:::
For âpartials,â I ended up using the official @next/mdx
plugin for this đ.
Generating A Sitemap and RSS Feed
If youâre optimizing for SEO, itâs a good idea to maintain a sitemap.xml
file to submit to search engine crawlers.
Also, if youâre writing a blog, people love to have RSS feeds like rss-feed.xml
of your content so they can aggregate it into their readers.
Turns out, both of these things are manual processes in Next.js.
In a discussion on GitHub about this very topic, Lee Robinson proposes using getServerSideProps
for generating a sitemap. Looks really slick!
I ended up merging my MDX posts and WordPress posts together like this:
import {getPosts} from '@/lib/posts';
import {getArchivePosts} from '@/lib/archive-posts';
export async function getServerSideProps({res}) {
const posts = await getPosts();
const {posts: archivePosts} = await getArchivePosts();
const allPosts = posts
.concat(archivePosts)
.filter((post) => !post.externalUrl);
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${allPosts
.map((post) => {
return `
<url>
<loc>${`https://jplhomer.org/${post.nextPath}`}</loc>
</url>
`;
})
.join('')}
</urlset>
`;
res.setHeader('Content-Type', 'text/xml');
res.write(sitemap);
res.end();
}
export default function Sitemap() {
return '';
}
This worked fine locally but failed once I deployed to Vercel.
Why? Because apparently getServerSideProps
and API routes do NOT have access to the filesystem like getStaticProps
. This is a bummer.
I ended up borrowing Leeâs method of generating the sitemap manually during the Webpack build step.
Big downsides of this include the fact that I canât import the same helper libraries Iâm using in my Next pages because they are written in ES Modules, and the sitemap generator script is limited to CommonJS. đ
Hereâs my finished script which creates both a sitemap and an RSS feed.
Building Glances
A really cool part about the new site is a section Iâve named Glances đ:
This is sort of like a self-hosted Instagram. I realized I share lots of different photos and videos to different social media platforms, but thereâs not really a single place where the âhighlightsâ live.
Glances are driven by Markdown files in a /glances
folder:
---
date: 2020-01-07
image: 'https://some.photo.com/here.jpg'
---
My son Barrett was born! He is five days old in this photo.
These are pulled in once again using getStaticProps
and parsed using front-matter
and marked
.
One cool thing to note about glances is the modal routing support. You might be familiar with this if youâve used Instagram:
- When clicking on a post, it loads in a modal
- When visiting a post or refreshing the page, it loads in a dedicated page.
How the heck does that work? Thereâs a neat example in the official repo. Here are the key parts I used in Glances:
export default function Glances({glances}) {
return glances.map((glance) => (
<Link
key={glance.slug}
href={`/glances?glanceSlug=${glance.slug}`}
as={`/glances/${glance.slug}`}
shallow={true}
>
<a>
<GlancePreview glance={glance} />
</a>
</Link>
));
}
The key part is the variation between the href
and as
attributes. Under the hood, Next navigates to a query string like ?glanceSlug=some-glance-slug
. But in the URL bar, it looks as though youâve completely changed routes to /glances/some-glance/slug
.
Then I can add a pages/glances/[slug].js
which can pull in the slug, load the glance, and pre-render the static paths of all the glances ahead of time.
::: warning Uh-oh!
One issue I ran into here was the fact that as soon as I would change a route query param, I would lose my glances
static props. This is because the entire component was being re-rendered, and no pre-defined props existed for that specific combination of page + query string.
The key thing to remember is to add shallow={true}
to the route to ensure the component isnât re-rendered.
:::
Hearts
Because I thought it would be fun, I wanted to add Instagram-style âlikesâ to my photos using a heart icon.
First, I leveraged a useLocalStorage hook to sync a given userâs âlikeâ status for a glance. I wrapped all of this functionality into my own hook, useHearts
:
import {useLocalStorage} from './use-localstorage';
import {useMemo} from 'react';
export function useHearts(slug, callback) {
const [glances, setGlances] = useLocalStorage('glance-likes', []);
const isLiked = useMemo(() => glances.includes(slug), [glances, slug]);
function toggleLike() {
if (isLiked) {
const newGlances = glances.filter((s) => s !== slug);
setGlances(newGlances);
if (callback) callback(-1);
} else {
let newGlances = [...glances];
newGlances.push(slug);
setGlances(newGlances);
if (callback) callback(1);
}
}
return [isLiked, toggleLike];
}
Then I can pull in the userâs like status:
const [isLiked, toggleLiked] = useHearts(glance.slug);
That took care of tracking likes locally, but I also wanted to persist a total count of likes remotely. This is where Firebase came in handy, based on a Jamstack Functions recipe by Lee Robinson.
I leveraged Nextâs API Routes to add a pages/api/toggle-glance-like.js
endpoint:
import db from '@/lib/db-admin';
export default async (req, res) => {
if (!req.method === 'POST') {
return res.status(400).json({
error: 'Only supported request method is POST',
});
}
if (!req.body.slug) {
return res.status(400).json({
error: 'Missing "slug" body parameter',
});
}
const delta = req.body.decrement ? -1 : 1;
const ref = db.ref('glance-likes').child(req.body.slug);
const {snapshot} = await ref.transaction((currentLikes) => {
if (currentLikes === null || currentLikes === 0) {
return Math.max(delta, 0);
}
return currentLikes + delta;
});
return res.status(200).json({
total: snapshot.val(),
});
};
Now, whenever the user clicks the heart (or double taps the photo), I POST
to that endpoint to toggle the like within Firebase.
Finally, I wanted to show the total number of likes for a given glance. I added a handy useFirebase
hook which let me display realtime data from a given table:
import {useState, useEffect} from 'react';
import loadDb from '@/lib/db';
export function useFirebase(collection, documentId) {
const [loading, setLoading] = useState(true);
const [value, setValue] = useState(null);
useEffect(() => {
const handleValue = (snapshot) => {
setValue(snapshot.val());
setLoading(false);
};
let db;
async function fetchValue() {
db = await loadDb();
db.ref(collection).child(documentId).on('value', handleValue);
}
fetchValue();
return () => {
if (db) {
db.ref(collection).child(documentId).off('value', handleValue);
}
};
});
return [value, loading, setValue];
}
Iâm using it like this:
const [totalLikes, likesLoading, setTotalLikes] = useFirebase(
'glance-likes',
glance.slug,
);
A couple things to note here:
- Asynchronous imports. I got some nasty Firebase initialization errors when trying to do this otherwise. This fix, once again, is thanks to Lee Robinson.
- Optional setter. I export a
setValue
optional third param, even though it only sets internal state. Why? Because itâs nice to have optimistic UI responses to an action like clicking the heart on a glance and seeing the number increment immediately. The total will be eventually consistent with the remote UI, because the Firebase event will eventually callback with the real total.
::: warning A note about private keys
When adding my FIREBASE_PRIVATE_KEY
to Vercel, I was getting an internal server error on the API endpoint along with a âInvalid PEMâ message. This is because Vercel was cutting off the first part of the private key when I added the value through the Environment Variable UI. Adding the value directly from the CLI did the trick for me. Iâm guessing this is a bug, and it should be fixed eventually.
:::
Glance Navigation
I really wanted to polish the UX for glances, so I implemented keyboard arrow navigation support on desktop using a hook called useKeyboard
:
import {useEffect} from 'react';
export function useKeyboard(key, callback) {
useEffect(() => {
/**
* @param {KeyboardEvent} e
*/
const handler = (e) => {
if (e.key === key) callback();
return;
};
window.addEventListener('keydown', handler);
return () => {
window.removeEventListener('keydown', handler);
};
});
}
Then I can pass a callback to switch the active glance in the route query params:
useKeyboard('ArrowRight', () => navigateGlance());
useKeyboard('ArrowLeft', () => navigateGlance(-1));
Instagramâs mobile interface is different than their desktop interface, and I wanted to reflect that here as well.
On mobile, Instagram sends you to what looks like a detail screen, but itâs really a long list of chronological posts that you can view by simply scrolling up or down on the page.
To implement this, I added a different query param ?glanceSlugScroll
, which allowed me to determine which view to display to the user based on a useMedia
media query hook:
const glanceLinkParam = useMedia(
['(min-width: 768px)'],
['glanceSlug'],
'glanceSlugScroll',
);
When a mobile user clicks on a post, I set the glanceScrollSlug
query param, and then scroll them to that position on the page (using a slight delay to allow for some loading):
export default function Glances({glances}) {
const isScrollActive = Boolean(router.query.glanceSlugScroll);
// ...
useEffect(() => {
if (router.query.glanceSlugScroll) {
const item = document.getElementById(router.query.glanceSlugScroll);
setTimeout(() => scroll(0, item.offsetTop), 100);
}
}, [router.query.glanceSlugScroll]);
return isScrollActive ? (
<ul>
{glances.map((glance) => (
<li id={glance.slug} key={glance.slug}>
<Glance glance={glance} />
</li>
))}
</ul>
) : (
<div>
{glances.map((glance) => (
<Link
key={glance.slug}
href={`/glances?${glanceLinkParam}=${glance.slug}`}
as={`/glances/${glance.slug}`}
shallow={true}
>
<a>
<GlancePreview glance={glance} />
</a>
</Link>
))}
</div>
);
}
It isnât perfect, but it works nicely enough.
Dark Mode
Gosh, Dark Mode sure is all the rage right now, isnât it?
I almost didnât ship this, because I assumed it would be a ton of work. But it wasnât, and I was pleasantly surprised!
This is in large part thanks to a couple packages:
- tailwind-css-mode by Chance Arthur
- use-dark-mode by donavon
Once I added the Tailwind plugin, I wired up the the useDarkMode
hook to the sun/moon icon in my header:
function DarkModeToggle() {
const darkMode = useDarkMode();
const isClient = useIsClient();
const iconClasses = 'w-5 h-5 inline-block';
if (isClient) {
return (
<button onClick={darkMode.toggle} aria-label="Toggle light and dark mode">
{darkMode.value ? (
<Lightbulb className={iconClasses} />
) : (
<Moon className={iconClasses} />
)}
</button>
);
}
return <span className="p-2 ml-2">...</span>;
}
The last step was to apply dark-only styles to my site using the Tailwind helper classes e.g. dark:bg-gray-800
. It was a lot fewer than I was expecting!
The dark mode hook should use your system and browser preferences by default, which is freaking amazing. So you should see my âlight modeâ site during the day, and my âdark modeâ site at night â unless you explicitly set the toggle, at which point it gets persisted in your localStorage.
Hosting
The site is hosted on Vercel. Itâs easy. Itâs fast.
Every branch and commit I push to GitHub automatically gets a unique URL to preview in-browser.
Plus, itâs integrated tightly with the Next.js framework (built by Vercel) so my static-generated pages get hoisted to the CDN edge so they are hot and ready for your viewing pleasure đ„.
Inspiration
I took a literal ton of inspiration from Lee Robinson, his personal site, and his codebase. You probably notice a resemblance in site design đ . Lee is a super smart person has some cool courses that you should totally purchase.
The Tailwind styles were heavily-influenced by Adam Wathanâs Tailwind job site that he just open-sourced. The .prose
class is đŻ and used a lot throughout the site.
I also took a lot of inspiration from Josh W. Comeau and his blog. Itâs more of a long-term goal than a short-term one, because there are so many cool things I could add like animations and sound effects in the future.
Special thanks to Kyle Brumm for giving me some design feedback đ
Thatâs it for me. Go ahead and follow me on Twitter at @jplhomer, or sign up for my newsletter if youâre interested in more.