On this page
- What You Will Build
- Prerequisites
- Overview
- Blog Blueprint
- Creating the Post Blueprint
- Adding Navigation
- Running the Migration
- Creating a Category Blueprint
- Creating a Tag Blueprint
- Linking Categories and Tags to Posts
- Testing in the Backend
- Listing Page
- Adding a Blog Link
- Building the Listing Page
- Try It Out
- Detail Page
- Creating the Detail Page
- Try It Out
- Categories
- Creating the Category Page
- Try It Out
- Tags
- Creating the Tag Cloud Partial
- Including the Tag Cloud
- Creating the Tag Filter Page
- Try It Out
- Going Further
- Comments
- Creating the Comment Blueprint
- Adding the Components
- Displaying Comments
- Creating the Comment Form
- Moderating Comments
- Try It Out
- RSS Feed
- Creating the Feed Page
- Adding the Feed Link
- Testing the Feed
- Summary
- Where to Go From Here
In this tutorial, you will build a complete blog from scratch using October CMS. By the end, you will have a working blog with a listing page, individual post pages, categories, tags, and an RSS feed, all without writing a single plugin.
What You Will Build
You will create a blog powered entirely by Tailor blueprints and CMS pages. The finished blog includes:
- A listing page that displays posts with featured images, excerpts, and pagination
- Detail pages for each post, accessed by slug, with full content and tags
- Category filtering so visitors can browse posts by topic
- Tag filtering so readers can find related content
- An RSS feed so readers can subscribe
No PHP code, no plugins. Just YAML blueprints for the content structure and Twig templates for the frontend.
Prerequisites
Before starting, you should have:
- October CMS installed and running
- A theme with a default layout that includes Tailwind CSS
If you haven't done this yet, work through the Quick Start guide first. This tutorial assumes you have a working layout similar to the one created there.
Overview
This tutorial is split into seven steps:
- Blog Blueprint: define the content structure for posts, categories, and tags
- Listing Page: display posts with pagination
- Detail Page: show a single post by its slug
- Categories: filter posts by category
- Tags: add a tag cloud with post counts
- Comments: let readers respond with moderated comments
- RSS Feed: add a subscription feed
Each step builds on the previous one. By the end, you will have a fully functional blog managed entirely through the backend.
Blog Blueprint
Before you can create blog posts, you need to define what a blog post looks like. In October CMS, this is done with a Tailor blueprint, a YAML file that describes the fields, behavior, and backend navigation for your content type.
Creating the Post Blueprint
- In your theme's directory, create a
blueprintsfolder if it doesn't exist. - Create a new file at
themes/mytheme/blueprints/blog/post.yaml. - Paste the following:
handle: Blog\Post
type: stream
name: Blog Post
customMessages:
buttonCreate: New Post
fields:
content:
label: Content
type: richeditor
span: adaptive
excerpt:
label: Excerpt
type: textarea
size: small
comment: A short summary shown on the listing page.
featured_image:
label: Featured Image
type: fileupload
mode: image
maxFiles: 1
is_featured:
label: Featured Post
type: switch
comment: Feature this post at the top of the listing.
What This Does
type: stream: a stream is designed for time-stamped entries like blog posts. Unlike theentrytype you used in the Quick Start, streams automatically sort by published date (newest first).richeditor: a visual editor for the post body.fileuploadwithmode: image: an image upload field limited to one file.customMessages: changes the "Create" button label in the backend to "New Post".- Tailor automatically adds title, slug, and published date fields to every entry blueprint, so you don't need to define those yourself.
Adding Navigation
To make your blog posts accessible from the backend sidebar, add navigation blocks to the blueprint. Update themes/mytheme/blueprints/blog/post.yaml to include navigation above the fields:
handle: Blog\Post
type: stream
name: Blog Post
customMessages:
buttonCreate: New Post
primaryNavigation:
label: Blog
icon: ph ph-pencil
order: 200
navigation:
label: Posts
order: 100
fields:
# ... (same fields as above)
The primaryNavigation creates a top-level Blog menu item. The navigation creates a Posts sub-item underneath it. Categories and tags will nest under this same menu later.
Running the Migration
After creating a blueprint, run the migration so Tailor can set up the database tables:
php artisan tailor:migrate
Or with DDEV:
ddev artisan tailor:migrate
Run this command every time you add or modify a blueprint.
Tip: You can also migrate blueprints from the Editor. When editing a blueprint file in the backend, click the Save & Migrate button to save and run the migration in one step.
Creating a Category Blueprint
Categories let you organize posts by topic. Create a new file at themes/mytheme/blueprints/blog/category.yaml:
handle: Blog\Category
type: structure
name: Category
structure:
maxDepth: 1
customMessages:
buttonCreate: New Category
navigation:
label: Categories
parent: Blog\Post
icon: ph ph-list-bullets
order: 200
fields:
description:
label: Description
type: textarea
size: small
What This Does
type: structure: entries with ordering support.maxDepth: 1keeps categories flat (no sub-categories).parent: Blog\Post: nests the Categories navigation item under the Blog menu.- Title and slug are added automatically, just like with posts.
Creating a Tag Blueprint
Tags provide a more flexible way to label posts. Create themes/mytheme/blueprints/blog/tag.yaml:
handle: Blog\Tag
type: entry
name: Tag
navigation:
label: Tags
parent: Blog\Post
icon: ph ph-tag
order: 300
fields:
posts:
type: entries
source: Blog\Post
inverse: tags
hidden: true
Tags only need a title and slug, which Tailor adds automatically. The posts field is an inverse relation that tells Tailor tags are connected to posts through the tags field on the post blueprint. Setting hidden: true keeps it out of the backend form since you don't need to edit it directly. You will use this relationship later to count how many posts each tag has.
Linking Categories and Tags to Posts
Now that the category and tag blueprints exist, update the post blueprint to reference them. Open themes/mytheme/blueprints/blog/post.yaml and add categories and tags fields at the bottom:
handle: Blog\Post
type: stream
name: Blog Post
customMessages:
buttonCreate: New Post
primaryNavigation:
label: Blog
icon: ph ph-pencil
order: 200
navigation:
label: Posts
order: 100
fields:
content:
label: Content
type: richeditor
span: adaptive
excerpt:
label: Excerpt
type: textarea
size: small
comment: A short summary shown on the listing page.
featured_image:
label: Featured Image
type: fileupload
mode: image
maxFiles: 1
is_featured:
label: Featured Post
type: switch
comment: Feature this post at the top of the listing.
categories:
label: Categories
type: entries
source: Blog\Category
tab: Manage
tags:
label: Tags
type: entries
source: Blog\Tag
displayMode: taglist
tab: Manage
What This Does
type: entries: creates a relationship to another blueprint. Posts can now be linked to categories and tags.source: Blog\Category: tells the field which blueprint to pull records from.displayMode: taglist: renders tags as a type-and-select input instead of a relation list, making it fast to add tags while editing a post.tab: Manage: groups these fields on a separate tab to keep the editing form clean.
Run the migration again to apply the changes:
php artisan tailor:migrate
Testing in the Backend
-
Refresh the backend. You should see Blog in the sidebar with Posts, Categories, and Tags sub-items.
-
Click Categories and create a few:
- Technology
- Travel
- Tutorials
-
Click Tags and create a few:
- Getting Started
- Tips
- October CMS
-
Click Posts and create some sample blog posts:
- Getting Started with October CMS: assign to Technology and Getting Started. Add an excerpt like
A beginner's guide to building websites with October CMS. - Building Your First Theme: assign to Tutorials and October CMS. Add an excerpt like
Learn how to create a theme from scratch with layouts, pages, and partials. - Weekend in the Mountains: assign to Travel and Tips. Add an excerpt like
A photo journal from a weekend hiking trip in the mountains.
- Getting Started with October CMS: assign to Technology and Getting Started. Add an excerpt like
Tip: When adding tags to a post, you can type a tag name directly into the tag list field. If the tag already exists, it will be suggested. If it doesn't, a new tag will be created for you.
Upload any images for the featured image field, or leave it blank for now. The frontend will handle missing images gracefully.
For the complete blueprint reference, see Blueprints and Content Fields in the developer documentation.
Listing Page
Now that your blog posts exist in the backend, you need a way to display them on your website. In this step, you will create a blog listing page that shows all published posts with pagination.
Adding a Blog Link
First, add a link to the blog in your layout's navigation. Open Editor → Layouts → default and add a blog link alongside your existing navigation links:
<a href="{{ 'blog'|page }}" class="text-gray-600 hover:text-gray-900">
Blog
</a>
Save the layout. The link won't work yet, you'll create the blog page next.
Building the Listing Page
- Navigate to Editor → Pages and click + Add.
- Fill in the settings:
- Title:
Blog - URL:
/blog - File Name:
blog - Layout: select
default
- Title:
- Click the Components panel and add:
- Collection: set its handle to
Blog\Post
- Collection: set its handle to
- In the markup editor, enter:
{% set posts = collection.paginate(6) %}
<h1 class="text-3xl font-bold mb-8">
Blog
</h1>
{% for post in posts %}
<article class="mb-8 pb-8 border-b border-gray-200">
{% if post.featured_image %}
<a href="{{ 'blog-post'|page({slug: post.slug}) }}">
<img
src="{{ post.featured_image.thumb(800, 400, {mode: 'crop'}) }}"
alt="{{ post.title }}"
class="w-full rounded-lg mb-4"
>
</a>
{% endif %}
<h2 class="text-2xl font-semibold mb-2">
<a href="{{ 'blog-post'|page({slug: post.slug}) }}" class="hover:text-blue-600">
{{ post.title }}
</a>
</h2>
<div class="text-sm text-gray-500 mb-3">
{{ post.published_at_date|date('F j, Y') }}
{% if post.categories is not empty %}
·
{% for category in post.categories %}
<a href="{{ 'blog-category'|page({slug: category.slug}) }}" class="text-blue-600 hover:underline">
{{ category.title }}
</a>{{ not loop.last ? ',' }}
{% endfor %}
{% endif %}
</div>
{% if post.excerpt %}
<p class="text-gray-600">
{{ post.excerpt }}
</p>
{% endif %}
</article>
{% else %}
<p class="text-gray-500">
No posts yet. Create some in the backend!
</p>
{% endfor %}
{{ pager(posts) }}
- Click Save.
Tip: Don't worry about the
blog-postandblog-categorylinks showing errors. Those pages don't exist yet. You will create them in the next steps.
How This Works
collection.paginate(6): fetches posts, 6 per page. Because the blueprint is astream, they are automatically sorted newest-first. No explicit sorting needed.post.published_at_date|date('F j, Y'): the stream's automatic published date field, formatted as "January 15, 2025".post.featured_image.thumb(800, 400, {mode: 'crop'}): generates a cropped thumbnail of the featured image at 800x400 pixels.'blog-post'|page({slug: post.slug}): generates the URL for the detail page using the post's slug.post.categories: accesses the categories relationship defined in the blueprint.{{ pager(posts) }}: renders pagination links (Previous, Next, page numbers).{% else %}on the for loop: displays a message when there are no posts.
Try It Out
- Preview your site and click the Blog link in the navigation.
- You should see your sample posts displayed newest-first, with featured images, dates, excerpts, and category links.
- If you have more than 6 posts, pagination links will appear at the bottom. Try creating a few more posts to see pagination in action.
For the full component reference, see Collection Component in the developer documentation.
Detail Page
When a visitor clicks on a blog post from the listing, they need to see the full post. In this step, you will create a detail page that displays a single blog post based on its URL slug.
Creating the Detail Page
- Navigate to Editor → Pages and click + Add.
- Fill in the settings:
- Title:
Blog Post - URL:
/blog/:slug - File Name:
blog-post - Layout: select
default
- Title:
- Click the Components panel and add:
- Section: set its handle to
Blog\Post
- Section: set its handle to
- In the markup editor, enter:
{% if section is empty %}
{% do abort(404) %}
{% endif %}
<article class="max-w-3xl">
<a href="{{ 'blog'|page }}" class="text-blue-600 hover:underline mb-6 inline-block">
← Back to blog
</a>
{% if section.featured_image %}
<img
src="{{ section.featured_image.thumb(900, 450, {mode: 'crop'}) }}"
alt="{{ section.title }}"
class="w-full rounded-lg mb-6"
>
{% endif %}
<h1 class="text-4xl font-bold mb-3">
{{ section.title }}
</h1>
<div class="text-sm text-gray-500 mb-8">
{{ section.published_at_date|date('F j, Y') }}
{% if section.categories is not empty %}
·
{% for category in section.categories %}
<a href="{{ 'blog-category'|page({slug: category.slug}) }}" class="text-blue-600 hover:underline">
{{ category.title }}
</a>{{ not loop.last ? ',' }}
{% endfor %}
{% endif %}
</div>
<div class="prose max-w-none">
{{ section.content|content }}
</div>
{% if section.tags is not empty %}
<div class="mt-8 pt-6 border-t border-gray-200">
<span class="text-sm font-medium text-gray-500 mr-2">
Tags:
</span>
{% for tag in section.tags %}
<a href="{{ 'blog-tag'|page({slug: tag.slug}) }}" class="inline-block bg-gray-100 text-gray-700 text-sm px-3 py-1 rounded-full mr-1 mb-1 hover:bg-gray-200">
{{ tag.title }}
</a>
{% endfor %}
</div>
{% endif %}
</article>
- Click Save.
How This Works
{% if section is empty %} {% do abort(404) %} {% endif %}: if no post matches the slug in the URL, a 404 page is returned. You already learned this pattern in the Quick Start.section.content|content: the|contentfilter processes rich editor output, resolving any internal links and media paths. Always use|contentwhen rendering richeditor fields.section.categoriesandsection.tags: the entries relationships defined in the blueprint are accessible as collections you can loop through.prose max-w-none: Tailwind's typography class for nicely formatted article content. This styles headings, paragraphs, lists, and other HTML elements within the post body.
Tip: The
proseclass is included with the Tailwind CSS CDN. If your blog content looks unstyled, make sure you have the Tailwind CDN link in your layout's<head>tag.
Try It Out
- Preview your site and go to the blog listing page.
- Click on any post title or featured image.
- You should see the full post with its featured image, published date, content, categories, and tags.
- Click the Back to blog link to return to the listing.
- Try visiting a URL with a slug that doesn't exist, like
/blog/nonexistent. You should see a 404 page.
For the full component reference, see Section Component in the developer documentation.
Categories
In the previous steps, you added category links to the listing and detail pages. Now let's create a dedicated page so visitors can browse all posts in a specific category.
Creating the Category Page
- Navigate to Editor → Pages and click + Add.
- Fill in the settings:
- Title:
Blog Category - URL:
/blog/category/:slug - File Name:
blog-category - Layout: select
default
- Title:
- Click the Components panel and add:
- Section: set its handle to
Blog\Category - Collection: set its handle to
Blog\Post
- Section: set its handle to
- In the markup editor, enter:
{% if section is empty %}
{% do abort(404) %}
{% endif %}
{% set posts = collection.whereRelation('categories', 'slug', section.slug).paginate(6) %}
<a href="{{ 'blog'|page }}" class="text-blue-600 hover:underline mb-6 inline-block">
← Back to blog
</a>
<h1 class="text-3xl font-bold mb-2">
{{ section.title }}
</h1>
{% if section.description %}
<p class="text-gray-600 mb-8">
{{ section.description }}
</p>
{% endif %}
{% for post in posts %}
<article class="mb-8 pb-8 border-b border-gray-200">
{% if post.featured_image %}
<a href="{{ 'blog-post'|page({slug: post.slug}) }}">
<img
src="{{ post.featured_image.thumb(800, 400, {mode: 'crop'}) }}"
alt="{{ post.title }}"
class="w-full rounded-lg mb-4"
>
</a>
{% endif %}
<h2 class="text-2xl font-semibold mb-2">
<a href="{{ 'blog-post'|page({slug: post.slug}) }}" class="hover:text-blue-600">
{{ post.title }}
</a>
</h2>
<div class="text-sm text-gray-500 mb-3">
{{ post.published_at_date|date('F j, Y') }}
</div>
{% if post.excerpt %}
<p class="text-gray-600">
{{ post.excerpt }}
</p>
{% endif %}
</article>
{% else %}
<p class="text-gray-500">
No posts in this category yet.
</p>
{% endfor %}
{{ pager(posts) }}
- Click Save.
How This Works
The key line is:
{% set posts = collection.whereRelation('categories', 'slug', section.slug).paginate(6) %}
section: loads the category record matching the:slugURL parameter, just like the post detail page loads a post.collection.whereRelation('categories', 'slug', section.slug): filters the post collection to only include posts where thecategoriesrelationship contains a record matching the current category's slug. This is the technique for filtering entries by a related blueprint..paginate(6): paginates the filtered results.
Everything else follows the same patterns from the listing page.
Try It Out
- Preview your site and go to the blog listing page.
- Click on a category link next to any post (e.g., "Technology").
- You should see only the posts assigned to that category, with the category name and description at the top.
- Click Back to blog to return to the full listing.
For the full component reference, see Collection Component and Entries Content Field in the developer documentation.
Tags
The category page filters posts using whereRelation. Tags will take a different approach. You will build a tag cloud partial that displays all tags with their post counts, then include it on multiple pages. This is a great way to learn how partials can contain their own components.
Creating the Tag Cloud Partial
Instead of adding the tag cloud markup directly to a page, you will create a reusable partial. This way, you can include it on the blog listing page, the category page, or anywhere else you want to show tags.
- Navigate to Editor → Partials and click + Add.
- Set the File Name to
blog/tag-cloud. - Click the Components panel and add:
- Collection: set its alias to
tagsand its handle toBlog\Tag
- Collection: set its alias to
- In the markup editor, enter:
{% set allTags = tags.withCount('posts').get() %}
{% if allTags is not empty %}
<div class="mt-12 pt-8 border-t border-gray-200">
<h2 class="text-lg font-semibold mb-4">
Browse by Tag
</h2>
<div class="flex flex-wrap gap-2">
{% for tag in allTags %}
<a href="{{ 'blog-tag'|page({slug: tag.slug}) }}" class="inline-flex items-center gap-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1.5 rounded-full text-sm">
{{ tag.title }}
<span class="bg-gray-300 text-gray-600 text-xs font-medium px-1.5 py-0.5 rounded-full">
{{ tag.posts_count }}
</span>
</a>
{% endfor %}
</div>
</div>
{% endif %}
- Click Save.
How This Works
- Partials can have components: the Collection component attached to this partial loads tag data independently. Any page that includes this partial gets the tag cloud without needing to add the component itself.
withCount('posts'): counts how many related posts each tag has. This uses the inverse relation you defined in the tag blueprint earlier.tag.posts_count: the count is available as an attribute named{relation}_count. Since the relation is calledposts, the attribute isposts_count..get(): returns all tags without pagination (tag lists are typically short).
Tip: The
withCountmethod works because the tag blueprint defines an inversepostsfield pointing back toBlog\Post. Without that inverse relation, Tailor wouldn't know how to count posts for each tag.
Including the Tag Cloud
Now add the tag cloud to your blog pages. Open the blog listing page (Editor → Pages → Blog) and add the following line at the bottom of the markup, after {{ pager(posts) }}:
{% partial 'blog/tag-cloud' %}
Save the page. You can include the same partial on the category page too. Open Editor → Pages → Blog Category and add the same line at the bottom, after {{ pager(posts) }}:
{% partial 'blog/tag-cloud' %}
The tag cloud now appears on both pages, and you only wrote the markup once.
Creating the Tag Filter Page
When a visitor clicks a tag, they need to see the matching posts. This works the same way as the category filter page.
- Navigate to Editor → Pages and click + Add.
- Fill in the settings:
- Title:
Blog Tag - URL:
/blog/tag/:slug - File Name:
blog-tag - Layout: select
default
- Title:
- Click the Components panel and add:
- Section: set its handle to
Blog\Tag - Collection: set its handle to
Blog\Post
- Section: set its handle to
- In the markup editor, enter:
{% if section is empty %}
{% do abort(404) %}
{% endif %}
{% set posts = collection.whereRelation('tags', 'slug', section.slug).paginate(6) %}
<a href="{{ 'blog'|page }}" class="text-blue-600 hover:underline mb-6 inline-block">
← Back to blog
</a>
<h1 class="text-3xl font-bold mb-8">
Posts tagged "{{ section.title }}"
</h1>
{% for post in posts %}
<article class="mb-8 pb-8 border-b border-gray-200">
{% if post.featured_image %}
<a href="{{ 'blog-post'|page({slug: post.slug}) }}">
<img
src="{{ post.featured_image.thumb(800, 400, {mode: 'crop'}) }}"
alt="{{ post.title }}"
class="w-full rounded-lg mb-4"
>
</a>
{% endif %}
<h2 class="text-2xl font-semibold mb-2">
<a href="{{ 'blog-post'|page({slug: post.slug}) }}" class="hover:text-blue-600">
{{ post.title }}
</a>
</h2>
<div class="text-sm text-gray-500 mb-3">
{{ post.published_at_date|date('F j, Y') }}
</div>
{% if post.excerpt %}
<p class="text-gray-600">
{{ post.excerpt }}
</p>
{% endif %}
</article>
{% else %}
<p class="text-gray-500">
No posts with this tag yet.
</p>
{% endfor %}
{{ pager(posts) }}
{% partial 'blog/tag-cloud' %}
- Click Save.
Try It Out
- Preview your site and go to the blog listing page. Below the posts, you should see a "Browse by Tag" section with all your tags and their post counts.
- Click on a tag to see the filtered posts.
- Notice the tag cloud appears on the tag filter page too.
- Navigate to a category page. The tag cloud is there as well.
- Go to a blog post detail page and click a tag pill at the bottom. It takes you to the same filtered view.
Going Further
Your blog is fully functional. Here are some ideas for extending it:
- Category list partial: apply the same pattern to create a reusable category list partial with
withCount. - Search: add a search page using the Collection component's
searchWheremethod to filter posts by title and content. - Authors: create an Author blueprint with avatar, bio, and social links, then link it to posts with an
entriesfield.
The demo theme includes all of these features and more. It is a great reference for extending your blog.
Comments
A blog feels alive when readers can respond to your posts. In this step, you will add a comment form to the detail page using a submission blueprint, a special Tailor blueprint type designed for accepting user generated content. Comments arrive with a pending status and only appear on the site after you approve them in the backend.
Creating the Comment Blueprint
Create a new file at themes/mytheme/blueprints/blog/comment.yaml:
handle: Blog\Comment
type: submission
name: Comment
submission:
titleTemplate: '{{ str_limit(content, 60) }}'
navigation:
label: Comments
parent: Blog\Post
icon: ph ph-chat-circle
order: 400
fields:
author_name:
label: Name
type: text
validation: required|min:2|max:100
author_email:
label: Email Address
type: email
validation: required|email
content:
label: Comment
type: textarea
validation: required|min:5|max:2000
post:
label: Post
type: entries
source: Blog\Post
maxItems: 1
What This Does
type: submission: a blueprint type made for user generated content. Records are created from the frontend, arrive as Pending, and stay hidden from visitors until approved.validation: standard validation rules applied when the form is submitted. Invalid input is rejected with an error message against the field.titleTemplate: builds the record title shown in the backend list from the first 60 characters of the comment.post: an entries field that links each comment to the post it was written on, just like the categories and tags fields on the post blueprint.
Warning: Every field defined on a submission blueprint accepts input from the frontend. Only define fields that visitors are allowed to populate, and keep internal fields like moderator notes out of the blueprint.
Run the migration to create the table:
php artisan tailor:migrate
Adding the Components
The detail page needs two components: a Collection to read the approved comments and a Submission to handle the form.
- Navigate to Editor → Pages and open the Blog Post page.
- Switch to the code editor view and add the following to the page's configuration section (the INI block at the top of the file):
[collection comments]
handle = "Blog\Comment"
[submission commentForm]
handle = "Blog\Comment"
Both components use the same Blog\Comment handle but do different jobs. The names comments and commentForm are component aliases, which let you refer to each one separately in the markup.
Displaying Comments
Still on the Blog Post page, add the comment list to the markup, just before the closing </article> tag:
<div class="mt-8 pt-6 border-t border-gray-200">
{% set postComments = comments.where('post_id', section.id).orderBy('created_at', 'asc').get() %}
<h2 class="text-2xl font-semibold mb-6">
{{ postComments.count }} {{ postComments.count == 1 ? 'comment' : 'comments' }}
</h2>
{% for comment in postComments %}
<div class="mb-6 {{ not loop.last ? 'pb-6 border-b border-gray-100' }}">
<div class="text-sm mb-2">
<span class="font-medium">
{{ comment.author_name }}
</span>
<span class="text-gray-500 ml-2">
{{ comment.created_at|date('F j, Y') }}
</span>
</div>
<p class="text-gray-700">
{{ comment.content|nl2br }}
</p>
</div>
{% else %}
<p class="text-gray-500">
Be the first to share your thoughts on this post.
</p>
{% endfor %}
<div class="mt-8">
<h3 class="text-xl font-semibold mb-4">
Leave a Comment
</h3>
{% ajaxPartial 'comment-form' %}
</div>
</div>
Click Save. The page will show an error until the partial exists, so create that next.
How This Works
comments.where('post_id', section.id): the Collection component returns a query you can refine. This filters comments to the current post using the relation column created by thepostfield, ordered oldest first like a conversation.comment.content|nl2br: escapes the comment text and converts line breaks to<br>tags, so multi-paragraph comments display correctly and any HTML a visitor types is rendered harmless.{% ajaxPartial %}: like{% partial %}, but the partial can refresh itself after an AJAX request. The comment form uses this to swap itself for a thank you message.
Creating the Comment Form
- Navigate to Editor → Partials and click + Add.
- Set the File Name to
comment-form. - Enter the following markup:
{% if formSubmitted %}
<div class="p-4 bg-green-50 text-green-700 rounded-md">
Thank you for your comment! It will appear here once it has been approved.
</div>
{% else %}
<form
data-request="commentForm::onFormSubmit"
data-request-update="{ _self: true }"
data-request-flash
>
<input type="hidden" name="post" value="{{ section.id }}">
<div class="grid md:grid-cols-2 gap-4 mb-4">
<div>
<label for="commentName" class="block text-sm font-medium mb-1">
Name
</label>
<input
type="text"
name="author_name"
id="commentName"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
required
>
</div>
<div>
<label for="commentEmail" class="block text-sm font-medium mb-1">
Email address
</label>
<input
type="email"
name="author_email"
id="commentEmail"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
required
>
</div>
</div>
<div class="mb-4">
<label for="commentContent" class="block text-sm font-medium mb-1">
Comment
</label>
<textarea
name="content"
id="commentContent"
rows="4"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
required
></textarea>
</div>
<div style="display:none">
<input type="text" name="_oc_hp" value="" autocomplete="off" tabindex="-1">
</div>
<button
type="submit"
data-attach-loading
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
Post Comment
</button>
</form>
{% endif %}
- Click Save.
How This Works
commentForm::onFormSubmit: calls theonFormSubmithandler on the Submission component, using thecommentForm::alias prefix you defined in the INI section. The handler validates the input against the blueprint rules and saves a pending comment.data-request-update="{ _self: true }": after a successful submission, the framework re-renders this partial. TheformSubmittedvariable is nowtrue, so the visitor sees the thank you message instead of the form.name="post"hidden field: fills thepostfield on the blueprint, linking the comment to the post being viewed._oc_hphidden field: a honeypot for spam protection. Humans never see it, but bots fill in every field, and any submission with a value here is blocked. The component also rate limits submissions to 6 per minute for each IP address.data-request-flash: shows validation errors as flash messages, using the setup from the Quick Start.
Moderating Comments
New comments do not appear on the site until you approve them.
- Preview your site, open a blog post, and submit a test comment. The thank you message appears, but the comment is not listed yet.
- In the backend, navigate to Blog → Comments. Your comment is listed with a Pending status.
- Tick the checkbox next to the comment. Approve, Reject, and Spam buttons appear above the list.
- Click Approve, then refresh the blog post on the frontend. The comment is now visible.
The three actions cover the moderation workflow:
- Approve: makes the comment visible on the frontend.
- Reject: hides the comment. Rejected comments are kept for 30 days and then deleted forever.
- Spam: rejects the comment and also rejects any other pending submissions from the same IP address, cleaning up bot floods in one click.
Tip: Use the status filter above the list to switch between Pending, Approved, and Rejected comments, so a busy comments section stays manageable.
Try It Out
- Submit a comment with a one-character name. The form shows a validation error, enforced by the
min:2rule on the blueprint. - Submit a valid comment and approve it in the backend. It appears on the post, oldest first.
- Submit a comment on a different post and confirm it only appears on that post after approval.
- Try rejecting a comment and watch it disappear from the pending list into the Rejected filter.
For the full reference, see Submission Component and Submission Blueprint in the developer documentation.
RSS Feed
An RSS feed lets readers subscribe to your blog using feed readers like Feedly or Inoreader. In this final step, you will create an RSS feed page that outputs your latest posts in XML format.
Creating the Feed Page
- Navigate to Editor → Pages and click + Add.
- Fill in the settings:
- Title:
Blog RSS Feed - URL:
/blog/rss - File Name:
blog-rss - Layout: leave empty (no layout)
- Title:
- Click the Components panel and add:
- Collection: set its handle to
Blog\Post
- Collection: set its handle to
- Switch to the code editor view and add the following to the page's configuration section (the INI block at the top of the file):
[resources]
headers[Content-Type] = 'text/xml'
- In the markup editor, enter:
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>My Blog</title>
<link>{{ 'blog'|page }}</link>
<description>The latest posts from my blog.</description>
<atom:link href="{{ 'blog-rss'|page }}" rel="self" type="application/rss+xml" />
{% for post in collection %}
<item>
<title>{{ post.title }}</title>
<link>{{ 'blog-post'|page({slug: post.slug}) }}</link>
<guid>{{ 'blog-post'|page({slug: post.slug}) }}</guid>
<pubDate>{{ post.published_at_date|date('r') }}</pubDate>
<description>{{ post.excerpt }}</description>
</item>
{% endfor %}
</channel>
</rss>
- Click Save.
How This Works
- No layout: by leaving the layout empty, the page outputs only its own markup. No HTML wrapper, no
<head>or<body>tags, just raw XML. headers[Content-Type] = 'text/xml': tells the browser to treat the response as XML instead of HTML.post.published_at_date|date('r'): therformat outputs an RFC 2822 date string, which is the format RSS requires for<pubDate>.collectionwithout.paginate(): iterates all posts without pagination. For a blog with many posts, you could limit the feed to the most recent entries withcollection.limit(20).get().
Adding the Feed Link
To help browsers and feed readers auto-discover your RSS feed, add a link tag to your layout. Open Editor → Layouts → default and add the following inside the <head> tag:
<link rel="alternate" type="application/rss+xml" title="Blog RSS Feed" href="{{ 'blog-rss'|page }}">
Save the layout.
Testing the Feed
- Visit
/blog/rssin your browser. You should see XML output with your blog posts listed as<item>elements. - Copy the full URL and paste it into a feed reader like Feedly or Inoreader to verify it works.
- You can also validate the feed structure at the W3C Feed Validation Service.
Summary
Congratulations! You have built a complete blog with October CMS. Here is what you created:
- Blueprints: four content types defined in YAML: a Blog Post stream, a Category structure, a Tag entry, and a Comment submission. No PHP code, no database SQL.
- Listing Page: a Collection component with pagination, displaying posts sorted newest-first.
- Detail Page: a Section component that resolves posts by slug, with 404 handling for missing entries.
- Category and Tag Filtering:
whereRelationqueries to filter posts, and a reusable tag cloud partial with post counts shared across pages. - Comments: a moderated comment form powered by a Submission component, with validation, spam protection, and an approval workflow.
- RSS Feed: XML output with a custom Content-Type header for feed reader subscriptions.
Everything is managed through the backend, from creating posts and assigning categories to configuring the published date. The frontend is powered entirely by Twig templates and Tailwind CSS.
Where to Go From Here
- Explore the demo theme for a more complete blog with search, archives, authors, and more
- Read the Tailor documentation for the full blueprint and content field reference
- Browse more tutorials in the Knowledge Base to continue building your skills
Was this article helpful?