24 KiB
How I built this blog, A Deep Dive into Modern Web Development.
A blog is one of the most powerful tools for sharing information, building authority, and engaging with an audience. When I decided to build a blog platform using Node.js, I wanted to go beyond the typical setup. I aimed for a feature-rich platform that dynamically serves content from Markdown files, supports pagination, integrates syntax highlighting for code snippets, offers a functional contact form with reCAPTCHA validation, and generates RSS feeds and sitemaps for better SEO.
In this in-depth technical breakdown, I’ll walk you through every aspect of the platform’s architecture and code, discussing why I chose each technology and how the different parts work together. If you're looking to create your own blog platform or simply want to dive deeper into building dynamic web applications with Node.js, this post will cover everything in great detail.
Why Node.js and Express?
Before we get into the nitty-gritty, let's talk about the choice of technologies. I chose Node.js as the runtime because of its event-driven, non-blocking I/O model, which is great for building scalable and performant web applications. Express.js, a minimalist web framework for Node, simplifies the process of setting up a web server, routing requests, and serving static files.
Here’s why these choices make sense for this project:
- Node.js: Handles high-concurrency applications well, meaning it can efficiently serve multiple blog readers without performance bottlenecks.
- Express.js: Provides a straightforward way to build a RESTful architecture for managing routes, handling form submissions, and rendering views dynamically.
Source
https://git.ssh.surf/snxraven/ravenscott-blog
Folder Structure: Organizing the Blog Platform
One of the first things you need to think about when building a project is its structure. Here's a breakdown of the folder structure I used for this blog platform:
/blog-platform
│
├── /markdown # Contains all blog posts written in Markdown
│ └── post-1.md # Example Markdown blog post
│
├── /public # Public assets (CSS, images, etc.)
│ └── /css
│ └── styles.css # Custom styles for the blog
│
├── /views # EJS templates (HTML views rendered by the server)
│ ├── index.ejs # Homepage template showing a list of blog posts
│ ├── blog-post.ejs # Template for individual blog posts
│ ├── about.ejs # "About Me" page
│ └── contact.ejs # Contact form page
│
├── app.js # Main server file, handles all backend logic
├── package.json # Project dependencies and scripts
├── .env # Environment variables (API keys, credentials, etc.)
└── README.md # Documentation
This structure provides a clear separation of concerns:
- Markdown files are stored in their own directory.
- Public assets (CSS, images) are isolated for easy reference.
- Views are where EJS templates are stored, allowing us to easily manage the HTML structure of each page.
- app.js acts as the control center, handling the routes, form submissions, and the logic for rendering content.
Setting Up the Express Server
The core of the application is the Express.js server, which powers the entire backend. In app.js
, we initialize Express, set up the middleware, and define the routes. But before we get into the route handling, let’s break down the middleware and configuration settings we used.
1. Loading Dependencies
Here are the key dependencies we load at the top of the file:
require('dotenv').config(); // Load environment variables from .env
const express = require('express');
const path = require('path');
const fs = require('fs');
const { marked } = require('marked'); // For parsing Markdown files
const nodemailer = require('nodemailer'); // For sending emails from the contact form
const hljs = require('highlight.js'); // For syntax highlighting in code blocks
const axios = require('axios'); // For making HTTP requests, e.g., reCAPTCHA verification
const { format } = require('date-fns'); // For formatting dates in RSS feeds and sitemaps
const app = express(); // Initialize Express
Here’s what each dependency does:
- dotenv: Loads environment variables from a
.env
file, which we use to store sensitive information like API keys and email credentials. - path and fs: Standard Node.js modules that help us work with file paths and file systems. We use these to read Markdown files and serve static assets.
- marked: A Markdown parser that converts Markdown syntax into HTML, which allows us to write blog posts using a simple syntax.
- nodemailer: Handles sending emails when users submit the contact form.
- highlight.js: Provides syntax highlighting for any code blocks in the blog posts. This is essential for making technical posts more readable.
- axios: Used for making external HTTP requests (e.g., verifying the Google reCAPTCHA response).
- date-fns: A utility for formatting dates, which we use to ensure dates are correctly formatted in RSS feeds and sitemaps.
2. Setting Up Middleware and Template Engine
Express makes it easy to set up middleware, which is crucial for handling static assets (like CSS files), parsing form data, and rendering templates using a view engine.
EJS Templating Engine
We use EJS as the templating engine. This allows us to embed JavaScript logic directly within our HTML, making it possible to render dynamic content like blog posts and form submission results.
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
This configuration tells Express to use the views
folder for storing the HTML templates and EJS
as the engine to render those templates.
Serving Static Files
Static files (like CSS and images) need to be served from the /public
directory. This is where we store the CSS styles used to make the blog look visually appealing.
app.use(express.static(path.join(__dirname, 'public')));
Parsing Form Data
When users submit the contact form, the form data is sent as URL-encoded data. To handle this, we use express.urlencoded
middleware, which parses the form submissions and makes the data accessible via req.body
.
app.use(express.urlencoded({ extended: false }));
Markdown Parsing with Syntax Highlighting
One of the primary features of this blog platform is that it allows you to write blog posts using Markdown. Markdown is a simple markup language that converts easily to HTML and is especially popular among developers because of its lightweight syntax for writing formatted text.
1. Setting Up marked
and highlight.js
To convert Markdown content to HTML, I used Marked.js, a fast and lightweight Markdown parser. Additionally, since many blog posts contain code snippets, Highlight.js is used to provide syntax highlighting for those snippets.
marked.setOptions({
highlight: function (code, language) {
const validLanguage = hljs.getLanguage(language) ? language : 'plaintext';
return hljs.highlight(validLanguage, code).value;
}
});
- highlight: The function accepts the code block and its language (e.g.,
javascript
,python
) and passes it tohighlight.js
for highlighting. If the language is invalid, we default to plain text.
This makes code blocks in blog posts look more readable by colorizing keywords, variables, and other syntax elements, which improves the user experience, especially for technical blogs.
Blog Post Storage and Rendering
1. Storing Blog Posts as Markdown Files
Instead of storing blog posts in a database, this platform uses a simpler approach: each blog post is a .md
file stored in the /markdown
directory. This approach is not only easier to manage, but it also gives writers the flexibility to create and update posts using any text editor.
Here’s what a sample Markdown file (post-1.md
) might look like:
- Lead Section: The comment
<-!-- lead -->
marks the start of the lead section. This is a short summary or introduction that appears on the homepage or in the RSS feed.
2. Rendering Markdown as HTML
To render the Markdown content as HTML on the frontend, we define a function loadMarkdownWithLead
that reads the Markdown file, parses it, and extracts the lead section if available.
function loadMarkdownWithLead(file) {
const markdownContent = fs.readFileSync(path.join(__dirname, 'markdown', file), 'utf-8');
let lead = '';
let contentMarkdown = markdownContent;
// Extract the lead section marked by `<-!-- lead -->`
const leadKeyword = '<-!-- lead -->';
if (contentMarkdown.includes(leadKeyword)) {
const [beforeLead, afterLead] = contentMarkdown.split(leadKeyword);
lead = afterLead.split('\n').find(line => line.trim() !== '').trim();
contentMarkdown = beforeLead + afterLead.replace(lead, '').
trim();
}
const contentHtml = marked.parse(contentMarkdown);
return { contentHtml, lead };
}
- Markdown Parsing: This function reads the Markdown file using
fs.readFileSync
, extracts the lead if present, and passes the remaining content tomarked
for parsing into HTML. - Lead Section: We use a special comment tag
<-!-- lead -->
to mark the start of the lead section. TheloadMarkdownWithLead
function extracts this section and removes it from the rest of the content, allowing us to display it separately on the homepage or in feeds.
3. Dynamically Rendering Blog Posts
For each blog post, we generate a URL based on its title (converted to a slug format). The user can access a blog post by navigating to /blog/{slug}
, where {slug}
is the URL-friendly version of the title.
Blog Post Route:
app.get('/blog/:slug', (req, res) => {
const slug = req.params.slug;
const markdownFile = fs.readdirSync(path.join(__dirname, 'markdown'))
.find(file => titleToSlug(file.replace('.md', '')) === slug);
if (markdownFile) {
const originalTitle = markdownFile.replace('.md', ''); // Retain original title case
const { contentHtml, lead } = loadMarkdownWithLead(markdownFile);
res.render('blog-post', {
title: originalTitle,
content: contentHtml,
lead: lead
});
} else {
res.redirect('/'); // If the post doesn't exist, redirect to the homepage
}
});
This route handles the logic for displaying a single blog post:
- We take the
slug
from the URL, convert it into a filename, and attempt to find the corresponding Markdown file. - If the file exists, we render it using the
blog-post.ejs
template, passing the title, lead, and content as variables.
4. Slug Generation for Blog Posts
The title of each post is converted into a URL-friendly slug, which is used in the post's URL. Here's the utility function for converting a title into a slug:
function titleToSlug(title) {
return title.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove non-alphanumeric characters
.replace(/\s+/g, '-'); // Replace spaces with dashes
}
This function ensures that each blog post URL is clean and readable, with no special characters or extra whitespace.
Pagination: Listing Blog Posts with Page Navigation
To improve the user experience, we add pagination to the homepage. Rather than displaying all blog posts at once, we only show a limited number of posts per page (e.g., 5 posts per page). Users can navigate between pages using pagination controls.
1. Paginating Blog Posts
To implement pagination, we first define a function getAllBlogPosts
that reads all the Markdown files, sorts them by date, and slices the list based on the current page:
function getAllBlogPosts(page = 1, postsPerPage = 5) {
const blogFiles = fs.readdirSync(path.join(__dirname, 'markdown')).filter(file => file.endsWith('.md'));
const totalPosts = blogFiles.length;
const totalPages = Math.ceil(totalPosts / postsPerPage);
const start = (page - 1) * postsPerPage;
const end = start + postsPerPage;
const paginatedFiles = blogFiles.slice(start, end);
const blogPosts = paginatedFiles.map(file => {
const title = file.replace('.md', '').replace(/-/g, ' '); // Keep original casing for title
const slug = titleToSlug(title);
const stats = fs.statSync(path.join(__dirname, 'markdown', file));
const dateCreated = new Date(stats.birthtime); // Get file creation date
const formattedDate = dateCreated.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
return {
title,
slug,
date: formattedDate
};
});
return { blogPosts, totalPages };
}
Here’s what’s happening:
- We load all Markdown files from the
/markdown
directory. - Pagination logic: We calculate how many posts to display per page. For example, if there are 10 posts and the user is on page 2, we slice the array of blog posts to show posts 6 through 10.
- Each blog post’s title, slug, and date are extracted and returned as part of the
blogPosts
array, which is then rendered on the homepage.
2. Rendering the Homepage with Pagination
The homepage route loads the blog posts for the current page and renders them in the index.ejs
template. Pagination controls are also added to navigate between pages.
app.get('/', (req, res) => {
const page = parseInt(req.query.page) || 1;
if (page < 1) {
return res.redirect(req.hostname);
}
const postsPerPage = 5; // Set how many posts to display per page
const { blogPosts, totalPages } = getAllBlogPosts(page, postsPerPage);
res.render('index', {
title: 'Raven Scott Blog',
blogPosts,
currentPage: page,
totalPages
});
});
- Current Page Handling: We extract the current page number from the query parameters (
req.query.page
). If no page is specified, we default to page 1. - Redirection: If the user tries to navigate to an invalid page (e.g., page 0 or below), they’re redirected to the homepage.
3. Pagination Controls in EJS
Here’s how we render the pagination controls in the index.ejs
template:
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mt-4">
<% if (currentPage > 1) { %>
<li class="page-item">
<a class="page-link" href="?page=<%= currentPage - 1 %>">Previous</a>
</li>
<% } %>
<% for (let i = 1; i <= totalPages; i++) { %>
<li class="page-item <%= currentPage === i ? 'active' : '' %>">
<a class="page-link" href="?page=<%= i %>"><%= i %></a>
</li>
<% } %>
<% if (currentPage < totalPages) { %>
<li class="page-item">
<a class="page-link" href="?page=<%= currentPage + 1 %>">Next</a>
</li>
<% } %>
</ul>
</nav>
This generates pagination links dynamically based on the current page and the total number of pages. The current page is highlighted, and users can navigate to the previous or next page easily.
Contact Form with Google reCAPTCHA
One of the essential features of this platform is the contact form, where users can submit messages that get sent to the admin via email. To prevent spam and bot submissions, we integrate Google reCAPTCHA for human verification.
1. Creating the Contact Form
The contact form is rendered in the contact.ejs
template. It includes fields for the user’s name, email, subject, and message, as well as a Google reCAPTCHA widget.
<form action="/contact" method="POST" class="mt-4">
<div class="mb-3">
<label for="name" class="form-label">Your Name<span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name" required>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email Address<span class="text-danger">*</span></label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-3">
<label for="subject" class="form-label">Subject<span class="text-danger">*</span></label>
<input type="text" class="form-control" id="subject" name="subject" required>
</div>
<div class="mb-3">
<label for="message" class="form-label">Your Message<span class="text-danger">*</span></label>
<textarea class="form-control" id="message" name="message" rows="6" required></textarea>
</div>
<div class="g-recaptcha" data-sitekey="<%= process.env.CAPTCHA_SITE_KEY %>"></div>
<button type="submit" class="btn btn-primary">Send Message</button>
</form>
The reCAPTCHA widget is included as a <div>
element with the data-sitekey
attribute. This ensures that the user needs to verify they are human before submitting the form.
2. Form Submission Handling and reCAPTCHA Validation
When the form is submitted, the data (along with the reCAPTCHA response token) is sent to the server, where it’s validated and processed.
app.post('/contact', async (req, res) => {
const { name, email, subject, message, 'g-recaptcha-response': captchaToken
} = req.body;
if (!name || !email || !subject || !message) {
return res.render('contact', { msg: 'All fields are required.' });
}
// Verify the reCAPTCHA token with Google
const captchaSecret = process.env.CAPTCHA_SECRET_KEY;
const captchaVerifyUrl = `https://www.google.com/recaptcha/api/siteverify?secret=${captchaSecret}&response=${captchaToken}`;
try {
const captchaResponse = await axios.post(captchaVerifyUrl);
if (!captchaResponse.data.success) {
return res.render('contact', { msg: 'Captcha verification failed.' });
}
// If CAPTCHA is verified, send email
const output = `
<p>You have a new contact request from <strong>${name}</strong>.</p>
<h3>Contact Details</h3>
<ul>
<li>Name: ${name}</li>
<li>Email: ${email}</li>
<li>Subject: ${subject}</li>
</ul>
<h3>Message</h3>
<p>${message}</p>
`;
// Send email using nodemailer
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS
}
});
const mailOptions = {
from: email,
to: process.env.RECEIVER_EMAIL,
subject,
html: output
};
transporter.sendMail(mailOptions, (error, info) => {
if (error) {
console.error(error);
return res.render('contact', { msg: 'An error occurred. Please try again.' });
} else {
console.log('Message sent: %s', info.messageId);
return res.render('contact', { msg: 'Your message has been sent successfully!' });
}
});
} catch (error) {
console.error('Error verifying reCAPTCHA:', error);
return res.render('contact', { msg: 'Captcha verification failed. Please try again.' });
}
});
- Basic Validation: We check that all required fields (name, email, subject, message) are filled in before proceeding.
- reCAPTCHA Verification: The reCAPTCHA token is sent to Google’s API for verification. If the response is valid, the form submission proceeds. Otherwise, an error message is displayed to the user.
- Sending Email: Once reCAPTCHA is verified, the form data is formatted into an HTML message and sent to the site administrator via email using Nodemailer.
Generating an RSS Feed
For users who want to subscribe to the blog, I added an RSS feed feature. RSS feeds allow users to get updates from the blog using feed readers. Each time a new post is published, it automatically appears in the feed.
RSS Feed Route
The route /rss
generates the RSS feed in XML format, listing all the recent blog posts. The feed includes the post’s title, description (lead), link, and publication date.
app.get('/rss', (req, res) => {
const blogFiles = fs.readdirSync(path.join(__dirname, 'markdown'))
.filter(file => file.endsWith('.md'))
.sort((a, b) => {
const statA = fs.statSync(path.join(__dirname, 'markdown', a)).birthtime;
const statB = fs.statSync(path.join(__dirname, 'markdown', b)).birthtime;
return statB - statA;
});
let rssFeed = `<?xml version="1.0" encoding="UTF-8" ?>\n<rss version="2.0">\n<channel>\n`;
rssFeed += `<title>Raven Scott Blog</title>\n`;
rssFeed += `<link>https://${req.hostname}</link>\n`;
rssFeed += `<description>This is the RSS feed for Raven Scott's blog.</description>\n`;
blogFiles.forEach(file => {
const { lead } = loadMarkdownWithLead(file);
const title = file.replace('.md', '');
const slug = titleToSlug(title);
const stats = fs.statSync(path.join(__dirname, 'markdown', file));
const lastModifiedDate = new Date(stats.birthtime).toUTCString();
rssFeed += `<item>\n`;
rssFeed += `<title>${title}</title>\n`;
rssFeed += `<link>https://${req.hostname}/blog/${slug}</link>\n`;
rssFeed += `<description>${lead || 'Read the full post on the blog.'}</description>\n`;
rssFeed += `<pubDate>${lastModifiedDate}</pubDate>\n`;
rssFeed += `<guid>https://${req.hostname}/blog/${slug}</guid>\n`;
rssFeed += `</item>\n`;
});
rssFeed += `</channel>\n</rss>`;
res.header('Content-Type', 'application/rss+xml');
res.send(rssFeed);
});
Generating a Sitemap for SEO
To help search engines like Google crawl and index the blog, I implemented a sitemap. The sitemap lists all the URLs on the site and includes metadata about each page (e.g., when it was last modified, how frequently it changes).
Sitemap Route
The /sitemap.xml
route generates an XML sitemap listing all the static and dynamic pages (like blog posts):
app.get('/sitemap.xml', (req, res) => {
const hostname = req.headers.host || 'http://localhost';
const blogFiles = fs.readdirSync(path.join(__dirname, 'markdown')).filter(file => file.endsWith('.md'));
const staticUrls = [
{ url: '/', changefreq: 'weekly', priority: 1.0 },
{ url: '/about', changefreq: 'monthly', priority: 0.8 },
{ url: '/contact', changefreq: 'monthly', priority: 0.8 }
];
const blogUrls = blogFiles.map(file => {
const title = file.replace('.md', '');
const slug = titleToSlug(title);
const stats = fs.statSync(path.join(__dirname, 'markdown', file));
const lastModifiedDate = format(new Date(stats.birthtime), 'yyyy-MM-dd');
return {
url: `/blog/${slug}`,
lastmod: lastModifiedDate,
changefreq: 'monthly',
priority: 0.9
};
});
const urls = [...staticUrls, ...blogUrls];
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n`;
urls.forEach(({ url, lastmod, changefreq, priority }) => {
sitemap += ` <url>\n`;
sitemap += ` <loc>https://${hostname}${url}</loc>\n`;
if (lastmod) {
sitemap += ` <lastmod>${lastmod}</lastmod>\n`;
}
sitemap += ` <changefreq>${changefreq}</changefreq>\n`;
sitemap += ` <priority>${priority}</priority>\n`;
sitemap += ` </url>\n`;
});
sitemap += `</urlset>`;
res.header('Content-Type', 'application/xml');
res.send(sitemap);
});
My thoughts
This blog platform demonstrates how you can combine several modern technologies to build a feature-rich website. The use of Node.js and Express offers a lightweight, fast, and scalable solution for serving content. By storing blog posts as Markdown files, we avoid the complexity of using a database while still providing dynamic functionality.