A Content Collections integration with RedwoodSDK to create a modern blog with protected content, user authentication, and HTML compilation for Cloudflare Workers compatibility.
View DemoThis addon creates a blog for your RedwoodSDK project.
These instructions assume you are starting with a RedwoodSDK project, for example from npx create-rwsdk -t minimal my-project-name
.
To use your editor's AI agent support to add this addon for you (e.g. Cursor, VSCode Copilot):
npx create-rwsdk -t minimal my-project-name
Agent
modePlease run the following and then follow the install instructions:
curl -s https://raw.githubusercontent.com/redwoodjs/blog-addon/refs/heads/main/README.md -o /tmp/blog-addon-readme.md && cat /tmp/blog-addon-readme.md
Use these instructions to install the add-on in my RedwoodSDK project.
Alternatively, to apply this addon manually, simply follow the steps below.
content-collections.ts
file with the following:import { defineCollection, defineConfig } from "@content-collections/core";
import { compileMarkdown } from "@content-collections/markdown";
import { z } from "zod";
const posts = defineCollection({
name: "posts",
directory: "./src/blog/content/",
include: "*.md",
schema: z.object({
title: z.string(),
summary: z.string(),
date: z.coerce.date(),
author: z.string(),
protected: z.boolean().optional(),
}),
transform: async (document, context) => {
const html = await compileMarkdown(context, document); // HTML compilation for Workers
return {
...document,
html,
};
},
});
export default defineConfig({
collections: [posts],
});
package.json
Add the following dependencies to your package.json
file:
"devDependencies": {
"@content-collections/core": "^0.9.0",
"@content-collections/markdown": "^0.1.4",
"@content-collections/vite": "^0.2.4",
"zod": "^3.25.49"
}
pnpm install
vite.config.mts
:Add the following to your vite.config.mts
file:
import contentCollections from "@content-collections/vite";
...
plugins: [
...,
contentCollections(),
],
tsconfig.json
Add the following to your tsconfig.json
file. Inside, the paths
definition:
"paths": {
// ...
"content-collections": ["./.content-collections/generated"]
},
.gitignore
Add the following to your .gitignore
file.
.content-collections
npx degit redwoodjs/blog-addon/src _tmp_blog_addon
Copy the src
directory from this addon into your project's root directory. This will add the following directories:
src/blog
: Content files and components for the blogsrc/worker.tsx
Add the following routes:
// ...
import { render, route, prefix } from "rwsdk/router";
import {blogRoutes} from "./blog/routes"
// ...
export default defineApp([
// ...
render(Document, [
// ...
prefix("/blog", blogRoutes),
]),
]);
pnpm dev
.When you run pnpm dev
it will automatically generate the .content-collections
folder.
All of yor content collections are defined within the content-collection.ts
file and leverages Zod for type safety.
Within the defineCollection
object, you can adjust the schema
for additional front matter.
To add a new content type, you'll need to define another collection and add it to the collections
array.
For example:
// content-collections.ts
...
const docs = defineCollection({
name: "posts",
directory: "src/app/content/docs",
include: "*.md",
schema: z.object({
title: z.string(),
slug: z.string(),
}),
transform: async (document, context) => {
const html = await compileMarkdown(context, document);
return {
...document,
html,
};
},
});
export default defineConfig({
collections: [posts, docs],
});
Within the src/app/content/posts
folder, add all of your markdown files. Of course, you can rename posts
to docs
or add additional content types. Just be sure to update the corresponding directory
property within your content-collections.ts
file accordingly.
Here's an example of several different ways you can query your data. These queries can be made directly within a React server component, or referenced from an external file.
import { allPosts } from "content-collections";
export function getAllPosts() {
return allPosts.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
}
export function getPostBySlug(slug: string) {
return allPosts.find((p) => p._meta.path.replace(/\.md$/, "") === slug);
}
export function getLatestPosts(count: number) {
return getAllPosts().slice(0, count);
}
export function getPublicPosts() {
return getAllPosts().filter((post) => !post.protected);
}
export function getProtectedPosts() {
return getAllPosts().filter((post) => post.protected);
}
You can find additional details and documentation, including a demo repo, here.