Creating a Blog with October CMS

Tutorial

On this page

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:

  1. Blog Blueprint: define the content structure for posts, categories, and tags
  2. Listing Page: display posts with pagination
  3. Detail Page: show a single post by its slug
  4. Categories: filter posts by category
  5. Tags: add a tag cloud with post counts
  6. Comments: let readers respond with moderated comments
  7. 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

  1. In your theme's directory, create a blueprints folder if it doesn't exist.
  2. Create a new file at themes/mytheme/blueprints/blog/post.yaml.
  3. 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 the entry type you used in the Quick Start, streams automatically sort by published date (newest first).
  • richeditor: a visual editor for the post body.
  • fileupload with mode: 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: 1 keeps 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

  1. Refresh the backend. You should see Blog in the sidebar with Posts, Categories, and Tags sub-items.

  2. Click Categories and create a few:

    • Technology
    • Travel
    • Tutorials
  3. Click Tags and create a few:

    • Getting Started
    • Tips
    • October CMS
  4. 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.

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.

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

  1. Navigate to Editor → Pages and click + Add.
  2. Fill in the settings:
    • Title: Blog
    • URL: /blog
    • File Name: blog
    • Layout: select default
  3. Click the Components panel and add:
    • Collection: set its handle to Blog\Post
  4. 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 %}
                &middot;
                {% 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) }}
  1. Click Save.

Tip: Don't worry about the blog-post and blog-category links 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 a stream, 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

  1. Preview your site and click the Blog link in the navigation.
  2. You should see your sample posts displayed newest-first, with featured images, dates, excerpts, and category links.
  3. 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

  1. Navigate to Editor → Pages and click + Add.
  2. Fill in the settings:
    • Title: Blog Post
    • URL: /blog/:slug
    • File Name: blog-post
    • Layout: select default
  3. Click the Components panel and add:
    • Section: set its handle to Blog\Post
  4. 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">
        &larr; 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 %}
            &middot;
            {% 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>
  1. 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 |content filter processes rich editor output, resolving any internal links and media paths. Always use |content when rendering richeditor fields.
  • section.categories and section.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 prose class 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

  1. Preview your site and go to the blog listing page.
  2. Click on any post title or featured image.
  3. You should see the full post with its featured image, published date, content, categories, and tags.
  4. Click the Back to blog link to return to the listing.
  5. 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

  1. Navigate to Editor → Pages and click + Add.
  2. Fill in the settings:
    • Title: Blog Category
    • URL: /blog/category/:slug
    • File Name: blog-category
    • Layout: select default
  3. Click the Components panel and add:
    • Section: set its handle to Blog\Category
    • Collection: set its handle to Blog\Post
  4. 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">
    &larr; 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) }}
  1. 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 :slug URL 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 the categories relationship 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

  1. Preview your site and go to the blog listing page.
  2. Click on a category link next to any post (e.g., "Technology").
  3. You should see only the posts assigned to that category, with the category name and description at the top.
  4. 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.

  1. Navigate to Editor → Partials and click + Add.
  2. Set the File Name to blog/tag-cloud.
  3. Click the Components panel and add:
    • Collection: set its alias to tags and its handle to Blog\Tag
  4. 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 %}
  1. 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 called posts, the attribute is posts_count.
  • .get(): returns all tags without pagination (tag lists are typically short).

Tip: The withCount method works because the tag blueprint defines an inverse posts field pointing back to Blog\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.

  1. Navigate to Editor → Pages and click + Add.
  2. Fill in the settings:
    • Title: Blog Tag
    • URL: /blog/tag/:slug
    • File Name: blog-tag
    • Layout: select default
  3. Click the Components panel and add:
    • Section: set its handle to Blog\Tag
    • Collection: set its handle to Blog\Post
  4. 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">
    &larr; 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' %}
  1. Click Save.

Try It Out

  1. 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.
  2. Click on a tag to see the filtered posts.
  3. Notice the tag cloud appears on the tag filter page too.
  4. Navigate to a category page. The tag cloud is there as well.
  5. 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 searchWhere method 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 entries field.

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.

  1. Navigate to Editor → Pages and open the Blog Post page.
  2. 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 the post field, 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

  1. Navigate to Editor → Partials and click + Add.
  2. Set the File Name to comment-form.
  3. 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 %}
  1. Click Save.

How This Works

  • commentForm::onFormSubmit: calls the onFormSubmit handler on the Submission component, using the commentForm:: 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. The formSubmitted variable is now true, so the visitor sees the thank you message instead of the form.
  • name="post" hidden field: fills the post field on the blueprint, linking the comment to the post being viewed.
  • _oc_hp hidden 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.

  1. Preview your site, open a blog post, and submit a test comment. The thank you message appears, but the comment is not listed yet.
  2. In the backend, navigate to Blog → Comments. Your comment is listed with a Pending status.
  3. Tick the checkbox next to the comment. Approve, Reject, and Spam buttons appear above the list.
  4. 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

  1. Submit a comment with a one-character name. The form shows a validation error, enforced by the min:2 rule on the blueprint.
  2. Submit a valid comment and approve it in the backend. It appears on the post, oldest first.
  3. Submit a comment on a different post and confirm it only appears on that post after approval.
  4. 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

  1. Navigate to Editor → Pages and click + Add.
  2. Fill in the settings:
    • Title: Blog RSS Feed
    • URL: /blog/rss
    • File Name: blog-rss
    • Layout: leave empty (no layout)
  3. Click the Components panel and add:
    • Collection: set its handle to Blog\Post
  4. 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'
  1. 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>
  1. 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'): the r format outputs an RFC 2822 date string, which is the format RSS requires for <pubDate>.
  • collection without .paginate(): iterates all posts without pagination. For a blog with many posts, you could limit the feed to the most recent entries with collection.limit(20).get().

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

  1. Visit /blog/rss in your browser. You should see XML output with your blog posts listed as <item> elements.
  2. Copy the full URL and paste it into a feed reader like Feedly or Inoreader to verify it works.
  3. 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:

  1. 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.
  2. Listing Page: a Collection component with pagination, displaying posts sorted newest-first.
  3. Detail Page: a Section component that resolves posts by slug, with 404 handling for missing entries.
  4. Category and Tag Filtering: whereRelation queries to filter posts, and a reusable tag cloud partial with post counts shared across pages.
  5. Comments: a moderated comment form powered by a Submission component, with validation, spam protection, and an approval workflow.
  6. 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

Was this article helpful?

comments powered by Disqus
Still need help?
October Forum

Community members share experiences, ask questions and exchange tips.

Discord

Talk with other October CMS developers in real time.

Custom Development

Hire the October CMS team to work on your website.

Contact Us

For bug reports and help with this website. See the roadmap or submit an idea on the Portal.