Back

Technical: How we built our markdown blog w/ Next.js, TypeScript, and Vercel

2024-07-27

We're thrilled to announce the launch of our new blog, built with Next.js, TypeScript, and hosted on Vercel's free tier. Our goal was to create a sleek and efficient platform for sharing updates, insights, and stories about our journey and products. In doing so, we've effectively created a simple, yet powerful blogging solution akin to Hugo or Jekyll but with the modern capabilities of Next.js.

image

Why We Chose Next.js and TypeScript

Next.js is a powerful React framework that offers a range of features ideal for building static and dynamic websites. Its ability to handle server-side rendering, static site generation, and API routes made it a perfect choice for our blog. By leveraging TypeScript, we ensured our codebase is robust and maintainable, benefiting from static typing and the vast TypeScript ecosystem.

Setting Up on Vercel

Hosting our blog on Vercel was a no-brainer. Vercel provides seamless integration with Next.js, offering automatic deployments and performance optimizations right out of the box. Their free tier is perfect for startups and small projects, providing the necessary features without any cost.

Creating a Next.js Blog: A Simple Hugo/Jekyll Equivalent

Our new blog provides us with the simplicity and functionality of static site generators like Hugo and Jekyll, but with the added flexibility and power of Next.js.

Inspiration from Midjourney: Animation and b00p Art

One of the standout features of our landing page is the animated b00p art. Inspired by Midjourney's creative animations, we aimed to bring a dynamic and engaging visual experience to our visitors. The b00p art animations add a playful touch, enhancing the overall user experience.

Shout Out to Jason Hargrove

We owe a huge thanks to our technical founder, Jason Hargrove, for providing the original b00p art. Jason's art was generated using custom 3D models in Blender, inspired by a handmade ink drawing. This blend of traditional and digital art techniques brings a unique aesthetic to our blog, making it visually appealing and distinctive.

Content Snippet

Here's a sneak peek at one of our recent blog posts:

---
title: 'Hello World! 🌎'
date: '2024-07-20'
image: '/b00ps-noforest-1.png'
---

# Meet Hi: the b00p named Hello World.

*Beep Boop,* World! 

![image](/b00ps-noforest-1.png)

I'm Hi, short for Hello World. I'm a b00p, version 2.01. My electronics are hosted on a yellow robot board, with red waves silkscreen.

"What's a b00p?" Well, we're the future of fun at work!

Here's a little secret: I'm the chattiest b00p around. My mission? Spreading smiles, one greeting at a time!

So, welcome to our corner of the web. Let's make the world a little bit brighter together!

*Boop beep*,

from Hi 🤖👋

image

How We Did It — Quick Start Guide

This guide will help you set up a Next.js blog project with Markdown support and a custom plugin to remove images from content snippets.

Prerequisites

  • Node.js (v14 or later)
  • npm or yarn

Setup

1. Initialize the Project

npx create-next-app@latest my-blog
cd my-blog

2. Install Dependencies

npm install gray-matter remark remark-html remark-strip-markdown unist-util-visit @tailwindcss/typography @tailwindcss/postcss7-compat

3. Setup Tailwind CSS

Follow the official Tailwind CSS installation guide: https://tailwindcss.com/docs/guides/nextjs

4. Project Structure

Ensure your project structure looks like this:

/my-blog
  /posts
    first-post.md
    second-post.md
  /src
    /app
      /blog
        /[id]
          page.tsx
        page.tsx
    /helpers
      stripImages.ts
  /public
    /images
      example.jpg

5. Create the Custom Plugin

Create a custom plugin to remove images from Markdown content.

  • helpers/stripImages.ts:
import { Plugin } from 'unified';
import { Node } from 'unist';
import { visit } from 'unist-util-visit';

interface ParentNode extends Node {
  children: Node[];
}

const stripImages: Plugin = () => {
  return (tree: Node) => {
    visit(tree, 'image', (node, index, parent) => {
      if (parent && (parent as ParentNode).children) {
        (parent as ParentNode).children.splice(index, 1);
      }
    });
  };
};

export default stripImages;

6. Create Markdown Posts

Create your Markdown files in the /posts directory.

  • posts/first-post.md:
---
title: '1st Blog from a Startup in web4'
date: '2024-07-26'
image: 'https://substackcdn.com/image/fetch/f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe0f3e7fe-4b3a-4d7a-b0d8-8ede0f45e789_1586x1038.png'
---

This is the first blog post content.

7. Create the Blog Landing Page

  • src/app/blog/page.tsx:
import fs from 'fs';
import path from 'path';
import Link from 'next/link';
import matter from 'gray-matter';
import { remark } from 'remark';
import strip from 'strip-markdown';
import stripImages from '../../helpers/stripImages';

const postsDirectory = path.join(process.cwd(), 'posts');

interface Post {
  id: string;
  title: string;
  date: string;
  image: string;
  snippet: string;
}

async function getPosts(): Promise<Post[]> {
  const filenames = fs.readdirSync(postsDirectory);
  const posts = await Promise.all(filenames.map(async (filename) => {
    const filePath = path.join(postsDirectory, filename);
    const fileContents = fs.readFileSync(filePath, 'utf8');
    const { content, data } = matter(fileContents);
    
    // Extract snippet from content, removing images
    const processedContent = await remark().use(stripImages).use(strip).process(content);
    const snippet = processedContent.toString().split(' ').slice(0, 20).join(' ') + '...';

    return {
      id: filename.replace(/\.md$/, ''),
      title: data.title,
      date: data.date,
      image: data.image,
      snippet,
    } as Post;
  }));

  return posts;
}

const Blog = async () => {
  const posts = await getPosts();

  return (
    <div className="container mx-auto px-4 py-8 max-w-7xl">
      <h1 className="text-4xl font-bold mb-8" style={{color: '#fff'}}>the b00ps blog</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
        {posts.map((post) => {
          const linkProps = { href: `/blog/${post.id}` };

          return (
            <div key={post.id} className="bg-white shadow-md rounded-lg overflow-hidden">
              <Link {...linkProps} className="block">
                <img src={post.image} alt={post.title} className="w-full h-64 object-cover"/>
                <div className="p-6">
                  <h2 className="text-2xl font-bold mb-2 text-gray-800">{post.title}</h2>
                  <p className="text-sm text-gray-500 mb-4">{post.date}</p>
                  <p className="text-gray-700 mb-4">{post.snippet}</p>
                  <p className="text-blue-600">Read more...</p>
                </div>
              </Link>
            </div>
          );
        })}
      </div>
    </div>
  );
};

export default Blog;

8. Create the Blog Post Page

  • src/app/blog/[id]/page.tsx:
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';
import remarkImages from 'remark-images';
import Link from 'next/link';

const postsDirectory = path.join(process.cwd(), 'posts');

interface PostData {
  title: string;
  date: string;
  contentHtml: string;
  image: string;
}

interface PostProps {
  params: {
    id: string;
  };
}

async function getPostData(id: string): Promise<PostData> {
  const filePath = path.join(postsDirectory, `${id}.md`);
  const fileContents = fs.readFileSync(filePath, 'utf8');

  const { content, data } = matter(fileContents);
  const processedContent = await remark().use(html).use(remarkImages).process(content);
  const contentHtml = processedContent.toString();

  return {
    title: data.title,
    date: data.date,
    contentHtml,
    image: data.image,
  };
}

const Post = async ({ params }: PostProps) => {
  const postData = await getPostData(params.id);

  return (
    <div className="container mx-auto px-4 py-8 max-w-2xl" style={{color: '#000'}}>
      <Link href="/blog" className="text-blue-600 hover:text-blue-800 mb-4 inline-block">&larr; Back to Blog</Link>
      <article className="bg-white shadow-md rounded-lg overflow-hidden">
        <div className="p-6">
          <h1 className="text-3xl font-bold mb-4 text-gray-800">{postData.title}</h1>
          <p className="text-sm text-gray-500 mb-6">{postData.date}</p>
          <div 
            className="prose prose-sm sm:prose lg:prose-lg mx-auto"
            dangerouslySetInnerHTML={{ __html: postData.contentHtml }} 
          />
        </div>
      </article>
    </div>
  );
};

export default Post;

Running the Project

Start the development server:

npm run dev

Visit http://localhost:3000/blog to see your blog landing page with posts.


This README provides a quick start guide to set up your Next.js blog project with support for Markdown content and a custom plugin to remove images from content snippets. Adjust paths and configuration as needed based on your project's structure.

Hope it helps! b33p b00p!

HomeBlog