Recently, I decided I'd like a blog for my portfolio.
One thing I wanted – to publish on any device. If I'm with my iPad at a coffee shop, I should be able to write a post, publish it, and have it appear on my website without writing any code.
So, I decided on the following stack
- Next.js – to build our frontend
- Tailwind CSS – to style our frontend
- Vercel – to deploy our frontend
- Ghost – to write our posts as a headless CMS
- Digital Ocean – to deploy Ghost
Let's get started shall we 🥸
Deploying Ghost on Digital Ocean
Ghost offers a sleek blog editor similar to Medium.
Digital Ocean offers high availability with plenty of storage for a relatively low cost (you can also deploy your front end on here too if you'd like). There are free alternatives out there but I've faced some configuration and database challenges.
Prerequisite: you need a domain (you can get one on Namecheap for a decent price) or subdomain (not a subdirectory like www.domain.xyz/blog).
Create the Digital Ocean Ghost Droplet
- Create a Ghost Droplet on Digital Ocean
- Select Droplet Size (I did the cheapest on the Regular Intel SSD for about $6/month)
- Determine other configurations such as datacenter region which should be closest to your audience. I also chose SSH.
- Once created you'll have an IP address
Now install ghost by entering the server to complete the setup.
ssh root@use_your_droplet_ip
You'll need to go to your domain name manager (e.g. Namecheap) and configure an A record using your droplet IP. You can also select to add a domain to Digital Ocean.
When you ssh, you'll be prompted for
- The blog URL that you set up with the IP
- An email (for SSL)
Your blog should now be live at that URL
Ghost Configuration
Navigate to your blog URL and configure your account.
Set your account to private since the front end will be built using Next.js (Settings > General)
Setting up Next.js and Tailwind
Configure Next.js
npx create-next-app@latest --ts
Follow this guide to quickly configure Tailwind CSS.
Do an npm run dev
and your website should be live at localhost:3000/
Getting Blog Posts to Show Up
Let's get rid of most things in index.tsx
and just have a blog title
import type { NextPage } from 'next';
import Head from 'next/head';
const Home: NextPage = () => {
return (
<div>
<Head>
<title>Welcome to My Blog</title>
<link rel='icon' href='/favicon.ico' />
</Head>
<main>
<h1 className='text-7xl p-4'>Blog</h1>
</main>
</div>
);
};
export default Home;
To interact with Ghost, we'll need to use its API. We can use their /posts/
endpoint to grab our blog posts.
We need to store two variables
- Content API Key
- Blog URL
Navigate to Settings > Integrations > Add Custom Integration
In your Next app, create .env.local
and add your two variables
CONTENT_API_KEY=<your-key>
BLOG_URL=https://blog.domain.dev
Create a new file lib/ghost.ts
- Import your two environment variables
-
fetch
the/posts
endpoint (with some additional fields) - Return the result
const { CONTENT_API_KEY, BLOG_URL } = process.env;
export async function getPosts() {
const res: any = await fetch(
`${BLOG_URL}/ghost/api/v3/content/posts/?key=${CONTENT_API_KEY}&fields=title,slug,custom_excerpt,feature_image,reading_time,published_at,meta_title,meta_description&formats=html`
).then((res) => res.json());
const posts = res.posts;
return posts;
}
Now we can use getStaticProps
to add our posts as props to our page to render.
I also took a look at the object returned from getPosts to create some typings.
import type { NextPage } from 'next';
import Head from 'next/head';
import { getPosts } from '../lib/ghost';
import { GetStaticProps } from 'next/types';
export const getStaticProps: GetStaticProps = async () => {
const posts = await getPosts();
if (!posts) {
return {
notFound: true,
};
}
return {
props: { posts },
revalidate: 120, // in secs, at most 1 request to ghost cms backend
};
};
interface IBlogProps {
posts: Post[];
}
export type Post = {
title: string;
slug: string;
custom_excerpt: string;
feature_image: string;
html: string;
reading_time: number;
published_at: Date;
meta_title: string;
meta_description: string;
};
const Home: NextPage<IBlogProps> = ({ posts }) => {
return (
<div>
<Head>
<title>Welcome to My Blog</title>
<link rel='icon' href='/favicon.ico' />
</Head>
<main>
<h1 className='text-7xl p-4'>Blog</h1>
<ul>
{posts.map((post) => (
<li key={post.slug} className='px-4 py-2'>
{post.title}
{/** Add a Divider line */}
{post.slug !== posts[posts.length - 1].slug && (
<div className='relative flex py-5 items-center'>
<div className='flex-grow border-t border-gray-300 mr-24'></div>
</div>
)}
</li>
))}
</ul>
</main>
</div>
);
};
export default Home;
Let's stylize the titles a bit more by refactoring the code into its own component.
Create a new file /components/blogCard.tsx
import Link from 'next/link';
import type { Post } from '../pages/index';
interface IBlogCardProps {
post: Post;
}
const BlogCard = ({ post }: IBlogCardProps) => {
const { title, slug, reading_time, published_at } = post;
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
};
return (
<div>
<Link href='/post/[slug]' as={`/post/${slug}`}>
<a className='md:text-2xl text-xl font-bold hover:text-indigo-200 transition duration-300'>
{title}
</a>
</Link>
<div className='md:flex'>
<div className='flex pt-4'>
<p className='italic md:text-sm text-xs'>
🗓 {new Date(published_at).toLocaleDateString('en-US', options)}
</p>
<p className='pl-7 text-gray-300 font-light md:text-sm text-xs'>
{reading_time} min
</p>
</div>
</div>
</div>
);
};
export default BlogCard;
The Link
element currently takes us to a dynamic route that we will need to set up in the next section.
Go back to index.tsx
and replace {post.title}
with the BlogCard
component
...
<li key={post.slug} className='px-4 py-2'>
<BlogCard post={post} />
...
Rendering a Single Blog Post
Our links currently don't take us anywhere. We need to grab blog content from Ghost at the /posts/slug/{slug}/
endpoint and render that content.
Let's add a new function getPost in ghost.ts
to do exactly that.
...
export async function getPost(slug: string) {
const res: any = await fetch(
`${BLOG_URL}/ghost/api/v3/content/posts/slug/${slug}?key=${CONTENT_API_KEY}&fields=title,slug,custom_excerpt,feature_image,reading_time,published_at,meta_title,meta_description&formats=html`
).then((res) => res.json());
const posts = res.posts;
return posts[0];
}
Create a new file pages/post/[slug].tsx
that will represent a dynamic route where [slug]
is a parameter. This route matches what we had in the Link
element href.
In this file, we need to
- Define
getStaticProps
that grabs our post content - Define
getStaticPaths
to generate static paths for the dynamic route - Load the blog content
import { useRouter } from 'next/router';
import { GetStaticPaths, GetStaticProps, NextPage } from 'next/types';
import { ParsedUrlQuery } from 'querystring';
import { Post } from '..';
import { getPost, getPosts } from '../../lib/ghost';
interface IContextParams extends ParsedUrlQuery {
slug: string;
}
export const getStaticProps: GetStaticProps = async (context) => {
const { slug } = context.params as IContextParams;
const post: string = await getPost(slug);
if (!post) {
return {
notFound: true,
};
}
return {
props: { post },
revalidate: 120, // in secs, at most 1 request to ghost cms backend
};
};
export const getStaticPaths: GetStaticPaths<IContextParams> = async () => {
const posts = await getPosts();
const paths = posts.map((post: Post) => ({
params: { slug: post.slug },
}));
return { paths, fallback: 'blocking' };
};
interface ISlugPostProps {
post: Post;
}
const Post: NextPage<ISlugPostProps> = ({ post }) => {
const router = useRouter();
if (router.isFallback) {
return <LoadingPage />;
}
return <BlogContent post={post} />;
};
const LoadingPage = () => {
return (
<div className='flex items-center justify-center'>
<h1 className='md:text-5xl text-3xl md:pb-12 pb-8'>Loading...</h1>
</div>
);
};
const BlogContent = ({ post }: ISlugPostProps) => {
const { title, published_at, reading_time, html } = post;
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
};
return (
<article className='flex flex-col items-start justify-center w-full max-w-2xl mx-auto'>
{/** TITLE */}
<div className='flex items-center justify-center'>
<h1 className='md:text-5xl text-3xl md:pb-12 pb-8 pt-4'>{title}</h1>
</div>
{/** DATE + READING TIME */}
<div className='flex pb-6'>
<p className='italic px-3 tag'>
🗓 {new Date(published_at).toLocaleDateString('en-US', options)}
</p>
<p className='pl-7 text-gray-300 font-light md:text-sm text-xs'>
{reading_time} min
</p>
</div>
{/** CONTENT */}
<section>
<div dangerouslySetInnerHTML={{ __html: html }}></div>
</section>
</article>
);
};
export default Post;
Now when we click a blog post, we're taken to a separate page and can see the content.
It doesn't look great though...
Styling Blog Post
Using Chrome Developer Tools, we can inspect the HTML of our blog post and see what classes are being used, and can style them. By default, Tailwind gets rid of styles for typical <h1>
, <h2>
, etc elements so we have to define that ourselves.
Create a new file styles/BlogPost.module.css
and we can style the elements to our liking.
.postFullContent {
min-height: 230px;
line-height: 1.6em;
@apply text-lg relative
}
.postFullContent a {
color: black;
box-shadow: inset 0 -1px 0 black;
@apply hover:text-indigo-200 transition duration-300
}
.postContent {
@apply flex flex-col items-center
}
.postContent p {
@apply mb-6 min-w-full
}
.postContent h2 {
line-height: 1.25em;
@apply md:text-3xl text-xl font-semibold mx-0 my-2 min-w-full
}
.postContent h3 {
line-height: 1.25em;
@apply md:text-2xl text-lg font-semibold mx-0 mt-2 mb-1 min-w-full
}
.postContent ol {
list-style: auto;
padding: revert;
@apply mb-6 min-w-full
}
.postContent ul {
list-style: disc;
padding: revert;
@apply mb-6 min-w-full
}
.postContent li {
word-break: break-word;
line-height: 1.6em;
@apply my-2 pl-1
}
.postContent blockquote {
margin: 0 0 1.5em;
padding: 0 1.5em;
border-left: 3px solid #D4C3F9;
@apply min-w-full
}
.postContent code {
color: #fff;
background: #000;
border-radius: 3px;
padding: 0 5px 2px;
line-height: 1em;
font-size: .8em;
}
.postContent pre {
overflow-x: auto;
margin: 1.5em 0 3em;
padding: 20px;
max-width: 100%;
border: 1px solid #000;
color: #e5eff5;
background: #0e0f11;
border-radius: 5px;
@apply min-w-full
}
.postContent pre > code {
background: transparent;
}
.postContent figure {
display: block;
padding: 0;
border: 0;
font: inherit;
font-size: 100%;
vertical-align: baseline;
@apply mt-3 mb-9
}
.postContent figure figcaption {
@apply font-light text-sm text-center my-4
}
.postContent em {
@apply font-medium
}
Add these styles to pages/post/[slug].tsx
...
import styles from '../../styles/BlogPost.module.css';
...
{/** CONTENT */}
<section className={styles.postFullContent}>
<div
className={styles.postContent}
dangerouslySetInnerHTML={{ __html: html }}
></div>
</section>;
Some things need to be styled at the global level, however.
Using the basic Ghost theme, I had to add the following to styles/globals.css
...
.kg-bookmark-container {
color: black;
min-height: 148px;
border-radius: 3px;
@apply flex md:flex-row flex-col
}
a.kg-bookmark-container {
word-break: break-word;
transition: all .2s ease-in-out;
background-color: transparent;
@apply border-2 border-black
}
.kg-bookmark-content {
align-items: flex-start;
padding: 20px;
@apply flex flex-grow flex-col justify-start md:order-none order-2
}
.kg-bookmark-title {
line-height: 1.5em;
@apply hover:text-indigo-200 transition duration-300 font-semibold text-[#372772]
}
.kg-bookmark-description {
color: black;
display: -webkit-box;
overflow-y: hidden;
margin-top: 12px;
max-height: 48px;
color: #5d7179;
font-size: 12px;
line-height: 1.5em;
font-weight: 400;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.kg-bookmark-metadata {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-top: 14px;
color: #5d7179;
font-size: 1.5rem;
font-weight: 400;
}
.kg-bookmark-icon {
margin-right: 8px;
@apply md:w-[22px] md:h-[22px] w-[18px] h-[18px]
}
.kg-bookmark-publisher {
overflow: hidden;
max-width: 240px;
line-height: 1.5em;
text-overflow: ellipsis;
white-space: nowrap;
@apply text-sm font-light
}
.kg-bookmark-thumbnail {
position: relative;
min-width: 33%;
max-height: 100%;
@apply min-h-[160px]
}
.kg-bookmark-thumbnail img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 0 3px 3px 0;
-o-object-fit: cover;
object-fit: cover;
display: block;
}
.kg-image {
max-width: 100%;
}
.kg-gallery-container {
display: flex;
flex-direction: column;
max-width: 1040px;
width: 100vw;
}
.kg-gallery-row {
display: flex;
flex-direction: row;
justify-content: center;
}
.kg-gallery-image:not(:first-of-type) {
margin: 0 0 0 0.75em;
}
.kg-gallery-image {
flex: 1.5 1 0%;
}
Deploying on Vercel
To deploy your blog, we can use Vercel for free.
- Push your code to GitHub
- Create a Vercel account with GitHub https://vercel.com/signup
- Import your blog
- Add your environment variables to Vercel (since they aren't pushed through Git)
Now, anytime you make a code change and push it to your main branch, Vercel will automatically deploy that new change.
You won't need to make a code change if you add a new blog post though, it'll automatically appear!
Peace – have fun writing ✌🏽
Top comments (0)