Analog provides a powerful content system out of the box. Drop some markdown files in the src/content folder, add some frontmatter, and you have a working blog. For many projects, this is all you need.
The @analogjs/content package also allows you to pull posts from a headless CMS like Contentful, Sanity, or some other external source. This post shows you how to provide your own content loaders and keep your component the same using the custom content resource APIs.
Understanding the Default Content System
Before diving into custom loaders, let's understand how the default system works. Analog provides Resources that fetch content files and content using Angular Resources underneath. When you call contentFilesResource(), Analog returns an Angular resource containing an array of ContentFile objects:
interface ContentFile<Attributes extends Record<string, any>> {
filename: string;
slug: string;
content?: string | object;
attributes: Attributes;
}
Each content file has a filename (the path to the file), a slug (derived from the filename or frontmatter), optional content (the rendered body), and attributes (parsed frontmatter). You typically define an interface for your attributes:
import { contentFilesResource } from '@analogjs/content/resources';
interface PostAttributes {
title: string;
description: string;
published: boolean;
publishedDate: string;
}
// In your component class
readonly posts = contentFilesResource<PostAttributes>();
// In your template, access the value using .value()
// @for (post of posts.value(); track post.slug) { ... }
The resource functions use Angular's resource() API under the hood, which means they return signals.
By default, loaders scan your src/content folder, parse the frontmatter, and make everything available through dependency injection.
The Content Loader Tokens
Analog exports two Injection tokens that control how content is loaded:
CONTENT_LIST_LOADER - Returns a function that provides an array of all content files. This powers contentFilesResource() and is used when you need to list or filter your content.
CONTENT_FILE_LOADER - Returns a function that provides a map of filenames to content loader functions. This powers contentFileResource() and is used when loading individual content files by slug.
You can provide your own implementations using the standard provide syntax to provide the data from your custom source.
Fetching Custom Content at Build Time
When building static sites with Analog, fetching content is done at build time. Customizing this behavior involves three pieces: a Vite plugin that fetches content and exposes it as a virtual module, custom content loaders that consume the pre-fetched data, and prerender configuration to generate routes for your external content.
Creating a Vite Plugin
Vite plugins can fetch external content during the build process and expose it through a virtual module.
For example, here's a plugin that fetches articles from dev.to:
// vite-plugins/devto-posts.ts
import { Plugin } from 'vite';
export interface DevToArticle {
id: number;
slug: string;
title: string;
description: string;
published_at: string;
body_markdown?: string;
cover_image?: string;
}
export function devToPostsPlugin(username: string): Plugin {
const virtualModuleId = 'virtual:devto-posts';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
let cachedPosts: string | null = null;
return {
name: 'devto-posts',
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId;
}
},
async load(id) {
if (id === resolvedVirtualModuleId) {
if (!cachedPosts) {
// Fetch article list
const response = await fetch(
`https://dev.to/api/articles?username=${username}&per_page=10`
);
const articles: DevToArticle[] = await response.json();
// Fetch full content for each article
const fullArticles = await Promise.all(
articles.map(async (article) => {
const full = await fetch(
`https://dev.to/api/articles/${article.id}`
).then((r) => r.json());
return full;
})
);
cachedPosts = JSON.stringify(fullArticles);
}
return `export const devToPosts = ${cachedPosts};`;
}
},
};
}
The plugin fetches articles once during the build, caches the result, and exposes them through a virtual module that can be imported anywhere in your application.
Adding Types for the Virtal Module
For type-safety, TypeScript needs a declaration file to understand the virtual module:
// src/app/data/devto-posts.d.ts
declare module 'virtual:devto-posts' {
import { DevToArticle } from '../vite-plugins/devto-posts';
export const devToPosts: DevToArticle[];
}
Custom Loaders with Build-Time Data
Now we create custom content loaders that use the pre-fetched data from the virtual module:
// src/app/providers/devto-content-loader.ts
import { Provider } from '@angular/core';
import {
ContentFile,
CONTENT_LIST_LOADER,
CONTENT_FILE_LOADER,
injectContentFiles,
injectContentFilesMap,
} from '@analogjs/content';
import { devToPosts, DevToArticle } from 'virtual:devto-posts';
export interface PostAttributes {
title: string;
slug: string;
description: string;
published: boolean;
publishedDate: string;
isDevTo?: boolean;
coverImage?: string;
}
/** Convert a dev.to article to the ContentFile format */
function toContentFile(article: DevToArticle): ContentFile<PostAttributes> {
return {
filename: article.slug,
slug: article.slug,
content: article.body_markdown,
attributes: {
title: article.title,
slug: article.slug,
description: article.description,
published: true,
publishedDate: article.published_at,
isDevTo: true,
coverImage: article.cover_image,
},
};
}
/** Custom list loader combining local markdown and dev.to posts */
export function withDevToContentListLoader(): Provider {
return {
provide: CONTENT_LIST_LOADER,
useFactory() {
return async () => {
// Get local markdown files
const localPosts = injectContentFiles<PostAttributes>();
// Convert dev.to posts to ContentFile format
const devToContentFiles = devToPosts.map(toContentFile);
// Combine and sort by date
return [...localPosts, ...devToContentFiles].sort(
(a, b) =>
new Date(b.attributes.publishedDate).getTime() -
new Date(a.attributes.publishedDate).getTime()
);
};
},
};
}
/** Custom file loader including dev.to posts */
export function withDevToContentFileLoader(): Provider {
return {
provide: CONTENT_FILE_LOADER,
useFactory() {
return async () => {
// Get local content files map
const localFilesMap = injectContentFilesMap();
// Add dev.to posts to the map
const devToFilesMap: Record<string, () => Promise<any>> = {};
for (const article of devToPosts) {
devToFilesMap[article.slug] = async () => ({
metadata: {
title: article.title,
slug: article.slug,
description: article.description,
published: true,
publishedDate: article.published_at,
isDevTo: true,
},
default: article.body_markdown,
});
}
return { ...localFilesMap, ...devToFilesMap };
};
},
};
}
/** Combined provider for both loaders */
export function withDevToContentLoader(): Provider[] {
return [
withDevToContentListLoader(),
withDevToContentFileLoader()
];
}
The loaders use injectContentFiles() and injectContentFilesMap() to get the default local content, then merge it with the build-time fetched dev.to posts.
Configuring Vite and Prerender Routes
Register the plugin and configure prerendering in your Vite config:
// vite.config.ts
import { defineConfig } from 'vite';
import analog from '@analogjs/platform';
import { devToPostsPlugin } from './vite-plugins/devto-posts';
export default defineConfig({
plugins: [
devToPostsPlugin('your-devto-username'),
analog({
static: true,
prerender: {
routes: async () => {
// Fetch slugs for prerendering
const response = await fetch(
'https://dev.to/api/articles?username=your-devto-username&per_page=10'
);
const articles = await response.json();
const devToSlugs = articles.map((a: any) => a.slug);
return [
'/',
'/blog',
// Local content
{ contentDir: '/src/content', transform: (file) => `/blog/posts/${file.attributes.slug}` },
// Dev.to posts
...devToSlugs.map((slug: string) => `/blog/posts/${slug}`),
];
},
},
}),
],
});
Finally, provide the custom loaders in your app configuration:
// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideFileRouter } from '@analogjs/router';
import { provideContent, withMarkdownRenderer } from '@analogjs/content';
import { withDevToContentLoader } from './providers/devto-content-loader';
export const appConfig: ApplicationConfig = {
providers: [
provideFileRouter(),
provideContent(
withMarkdownRenderer(),
...withDevToContentLoader()
),
],
};
Using the Content in Components
Your components use contentFilesResource() and contentFileResource() exactly as they would with local content:
// src/app/pages/blog.page.ts
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { contentFilesResource } from '@analogjs/content/resources';
import { PostAttributes } from '../providers/devto-content-loader';
@Component({
imports: [RouterLink],
template: `
<h1>Blog</h1>
<ul>
@for (post of posts.value(); track post.slug) {
<li>
<a [routerLink]="['/blog/posts', post.slug]">
{{ post.attributes.title }}
</a>
</li>
}
</ul>
`,
})
export default class BlogComponent {
readonly posts = contentFilesResource<PostAttributes>();
}
For individual posts:
// src/app/pages/blog/posts/[slug].page.ts
import { Component } from '@angular/core';
import { MarkdownComponent } from '@analogjs/content';
import { contentFileResource } from '@analogjs/content/resources';
import { PostAttributes } from '../../../providers/devto-content-loader';
@Component({
imports: [MarkdownComponent],
template: `
@if (post.value(); as post) {
<article>
<h1>{{ post.attributes.title }}</h1>
<analog-markdown [content]="post.content"></analog-markdown>
</article>
}
`,
})
export default class BlogPostComponent {
readonly post = contentFileResource<PostAttributes>();
}
The components are completely agnostic to where the content comes from. Local markdown files and dev.to articles are presented through the same unified API.
Custom content file loaders give you the flexibility to fetch content from anywhere while maintaining a consistent API in your components. By combining Vite plugins for build-time fetching with custom loader tokens, you can integrate external content sources like dev.to, headless CMS platforms, or databases - all while your components continue using contentFilesResource() and contentFileResource() without any changes.
Check out the Analog documentation for more details on the content system.
If you enjoyed this post, click the ❤️ so other people will see it. Follow me on Bluesky, X, and subscribe to my YouTube Channel for more content!
Top comments (0)