We Made an Angular Blog from Markdown Files

PivotPoint June 19, 2025

Image uploaded by pivotpoint

Building a Static Blog with Angular 19

Why Build Our Own Blog?

We could have used Wix, WordPress, or any other off-the-shelf solution for hosting the PivotPoint blog. Platforms like these offer turnkey solutions with user-friendly editors, pre-built themes, and hosting included—so why did we roll up our sleeves and build a custom blog from scratch using Angular 19? The answer boils down to three core reasons: our passion for coding, the need for infinite flexibility, and the desire to maintain full control over our digital presence.

1. We Love Writing Code

At PivotPoint, coding isn’t just a means to an end - it’s our craft and our passion. By creating a custom solution, we could:

  • Tailor the Experience: Craft a blog that aligns perfectly with our brand and technical vision
  • Stay Cutting-Edge: Use the latest Angular features, such as signal-based reactivity and Vite-powered development, which results in quite a performant blog platform
  • Learn and Grow: Tackle challenges like dynamic route generation, resolver optimization, and prerendering configuration, sharpening our skills along the way

2. Infinite Flexibility

Off-the-shelf platforms come with constraints like predefined templates, plugin ecosystems, and limited customization options. While these are great for quick setups, they often fall short when you need bespoke functionality. Our custom blog platform gives us:

  • Dynamic Content Pipeline: Our custom build-time script transforms markdown files into static HTML, automatically generating routes and a sitemap. Adding a new post is as simple as dropping a markdown file into public/blogs/.
  • Custom SEO Control: We fine-tune meta tags, Open Graph, and X Card metadata for each post using Angular’s Meta and Title services, ensuring optimal search engine visibility.
  • Scalable Architecture: The modular setup with BlogPostResolver and standalone components allows us to add features like custom analytics, interactive widgets, or API-driven content without wrestling with platform limitations.
  • Performance Optimization: Prerendering static HTML ensures lightning-fast load times, and we can integrate tools like Vite or lazy-loaded modules to keep performance top-notch.

3. Control Over Our Livelihood

As developers, we understand the importance of owning our digital destiny. Relying on a third-party platform introduces risks: unexpected pricing changes, deprecated features, or platform-specific outages. By building our own blog, we maintain:

  • Full Ownership: Our code, hosting, and content pipeline are under our control, hosted on our chosen infrastructure. We’re not at the mercy of a platform’s roadmap or policies, and we don't believe you should be, either.
  • Data Sovereignty: We control how data is stored and processed, ensuring compliance with our privacy standards and avoiding lock-in to proprietary ecosystems. Again, we don't believe in vendor lock-in.
  • Custom Deployment Options: We can serve the blog as static HTML for simplicity and cost-efficiency or use dynamic SSR with server.ts for real-time rendering, giving us the freedom to choose what suits our needs.
  • Future-Proofing: We can adapt to new technologies or requirements without starting over.

By choosing to build our own blog, we’ve created a platform that's testament to our commitment to craftsmanship, flexibility, and independence. The PivotPoint blog is a living project, evolving with each new post and technical innovation we explore.

Cool, but how does it work?

Angular and Prerendering

Angular 19 introduced significant improvements to server-side rendering (SSR) and prerendering, integrating these features directly into the core framework with @angular/ssr. This eliminated the need for separate Angular Universal packages (which were a pain before...), making it easier to create static sites with excellent SEO. Our goal was to create a blog where:

  1. Content is authored in markdown for simplicity.
  2. Pages are prerendered as static HTML for fast loading and SEO.
  3. Routes are dynamically generated based on blog posts.
  4. The app can be served statically in production or with dynamic SSR for flexibility.

The Setup: Key Components

Our blog is powered by a combination of Angular 19's prerendering, a custom script to process markdown files, and a robust routing system. Basically, we write markdown that is transformed into HTML at build time so our pages are readily availible for crawlers and readers.

1. Markdown Files as Our Content Source

Blog posts are stored as markdown files in the public/blogs/ directory. Each file includes frontmatter for metadata (e.g., title, date, author, image, description, keywords). For example:

---
title: "The Myth of the 10x Software Engineer"
date: "2025-06-21"
author: "Robert Connolly"
image: "image path"
description: "You're looking at one"
keywords:
  - 10x engineer
  - software engineering
  - developer productivity
  - engineering myths
  - angular blog
---
# Title
content here...

These files are processed at build time to generate static HTML and dynamic routes.

2. Generating Blog Data and Routes

We created a custom Node.js script, generate-blog-data.mjs, to process markdown files and generate:

  • A blog-data.ts file containing an array of blog posts with slugs, HTML content (converted using the marked package), and metadata.
  • A prerender-routes.txt file listing all routes to prerender, including static routes (/, /home, /blog) and dynamic blog routes (e.g., /blog/the-myth-of-the-10x-software-engineer).
  • A sitemap.xml for SEO, including all routes with metadata like lastmod, changefreq, and priority.

The script uses the gray-matter package to parse frontmatter and marked to convert markdown to HTML. Here's a simplified version:

import fs from 'fs-extra';
import path from 'path';
import matter from 'gray-matter';
import { marked } from 'marked';

const blogDir = path.resolve('./public/blogs');
const outFile = path.resolve('./src/app/blog/blog-data.ts');
const prerenderRoutesFile = path.resolve('./prerender-routes.txt');

const posts = [];
for (const file of await fs.readdir(blogDir)) {
  if (file.endsWith('.md')) {
    const content = await fs.readFile(path.join(blogDir, file), 'utf-8');
    const { data, content: mdContent } = matter(content);
    const slug = (data.title || file.replace(/\.md$/, '')).replace(/[^a-z0-9\s-]/gi, '').replace(/\s+/g, '-').toLowerCase();
    const html = marked(mdContent);
    posts.push({ slug, html, metadata: data });
  }
}

const fileContent = `export const BLOG_POSTS = ${JSON.stringify(posts, null, 2)};`;
await fs.outputFile(outFile, fileContent);

const staticRoutes = [
  ...
  { loc: '/home', changefreq: 'monthly', priority: 0.9 },
  { loc: '/blog', changefreq: 'weekly', priority: 0.9 }
];
const dynamicRoutes = posts.map(post => ({
  loc: `/blog/${post.slug}`,
  lastmod: post.metadata.date || new Date().toISOString().split('T')[0],
  changefreq: 'monthly',
  priority: priority
}));
const allRoutes = [...staticRoutes, ...dynamicRoutes];

await fs.outputFile(prerenderRoutesFile, allRoutes.map(route => route.loc).join('\n'));

This script runs as part of the npm run build and npm run start commands, ensuring routes are dynamically generated based on blog posts.

3. Angular Routing and Resolver

We defined routes in routes.ts to handle static and dynamic pages:

import { Routes } from '@angular/router';
import { LandingPageComponent } from './landing-page/landing-page.component';
import { BlogListComponent } from './blog/blog-list.component';
import { BlogPostComponent } from './blog/blog-post.component';
import { BlogPostResolver } from './blog/blog-post.resolver';

export const routes: Routes = [
   ...
  { path: 'blog', component: BlogListComponent },
  {
    path: 'blog/:slug',
    component: BlogPostComponent,
    resolve: { post: BlogPostResolver }
  },
];

The BlogPostResolver fetches blog post data by slug from blog-data.ts:

import { Injectable } from '@angular/core';
import { Resolve, ActivatedRouteSnapshot } from '@angular/router';
import { BLOG_POSTS } from './blog-data';

@Injectable({ providedIn: 'root' })
export class BlogPostResolver implements Resolve<any> {
  resolve(route: ActivatedRouteSnapshot) {
    const slug = route.paramMap.get('slug');
    const post = BLOG_POSTS.find((p: any) => p.slug === slug);
    return post || { slug, html: '<p>Post not found</p>', metadata: { title: 'Not Found' } };
  }
}

The BlogPostComponent renders the HTML content and sets SEO meta tags:

import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Meta, Title } from '@angular/platform-browser';
import { CommonModule, DatePipe } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';

@Component({
  selector: 'app-blog-post',
  standalone: true,
  imports: [CommonModule, DatePipe],
  template: `
    <div *ngIf="postData; else notFound">
      <h1>{{ postData.metadata.title }}</h1>
      <div [innerHTML]="safeHtml"></div>
      <p>Published: {{ postData.metadata.date | date }}</p>
    </div>
    <ng-template #notFound>
      <h1>Post Not Found</h1>
      <p>The requested blog post could not be found.</p>
    </ng-template>
  `
})
export class BlogPostComponent {
  postData: any;
  safeHtml: SafeHtml | null = null;

  constructor(private meta: Meta, private titleService: Title, private sanitizer: DomSanitizer) {
    const route = inject(ActivatedRoute);
    this.postData = route.snapshot.data['post'];

    if (!this.postData) {
      this.titleService.setTitle('Post Not Found');
      this.meta.updateTag({ name: 'description', content: 'Blog post not found.' });
      return;
    }

    this.safeHtml = this.sanitizer.bypassSecurityTrustHtml(this.postData.html);

    const title = this.postData.metadata.title;
    const desc = this.postData.metadata.description || '';

    this.titleService.setTitle(title);

    this.meta.updateTag({ name: 'description', content: desc });
    this.meta.updateTag({ property: 'og:title', content: title });
    this.meta.updateTag({ property: 'og:description', content: desc });
    this.meta.updateTag({ name: 'twitter:title', content: title });
    this.meta.updateTag({ property: 'og:image', content: this.postData.metadata.image });
    this.meta.updateTag({ name: 'twitter:image', content: this.postData.metadata.image });
    this.meta.updateTag({ name: 'keywords', content: this.postData.metadata.keywords.join(', ') });
  }
}

4. Prerendering with Angular 19

We use Angular 19’s built-in prerendering (@angular-devkit/build-angular:prerender) to generate static HTML files at build time. The angular.json configuration specifies:

"prerender": {
  "builder": "@angular-devkit/build-angular:prerender",
  "options": {
    "browserTarget": "frontend:build:production",
    "serverTarget": "frontend:server:production",
    "routesFile": "prerender-routes.txt"
  }
}

The prerender-routes.txt file is dynamically generated by generate-blog-data.mjs, ensuring new blog posts are automatically included without updating angular.json. The main.server.ts file handles the rendering:

import { bootstrapApplication } from '@angular/platform-browser';
import { renderApplication } from '@angular/platform-server';
import { provideServerRendering } from '@angular/platform-server';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));

export function bootstrap() {
  return bootstrapApplication(AppComponent, {
    ...appConfig,
    providers: [provideServerRendering(), ...(appConfig.providers || [])]
  });
}

export default async function render(url: string): Promise<string> {
  if (!url) throw new Error('URL is undefined');

  const document = readFileSync(join(__dirname, '../browser/index.html'), 'utf-8');
  try {
    return await renderApplication(bootstrap, { document, url });
  } 
  catch (error) {
    throw error;
  }
}

5. Running in Production

To build and serve the app:

  • Static Hosting: Serve with a static server like serve:
    npm install -g serve
    serve dist/frontend -p 4200
    
  • Dynamic SSR: Run npm run serve:ssr:frontend for real-time rendering:
    node dist/frontend/server/server.mjs
    

What's Next?

We’re just getting started. Here’s what we’re planning next:

  • Comments & Interactivity: Integrate a commenting system or custom widgets for reader engagement.
  • Author Pages: Dedicated pages for each author, showcasing their posts and bio.
  • Tag & Category Filtering: Make it easier to browse posts by topic or tag.
  • Content Previews & Summaries: Show post previews on the blog list page for better UX.
  • Performance & Accessibility: Continue optimizing for speed, accessibility, and SEO.
  • Open Source: We may open source our blog tooling and share more technical deep-dives.

And, of course, POSTS.

Have ideas or feedback? Reach out! We’d love to hear from you!

Other recent posts