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
, andX Card
metadata for each post using Angular’sMeta
andTitle
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:
- Content is authored in markdown for simplicity.
- Pages are prerendered as static HTML for fast loading and SEO.
- Routes are dynamically generated based on blog posts.
- 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 themarked
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 likelastmod
,changefreq
, andpriority
.
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!