Simple Comments for Rails Sites

Jon Sully

12 Minutes

A simple, AI-driven solution for adding comments to your Rails site

Following my own advice, when it finally came time to leave Gatsby (since it’s dead and unmaintained — my experience essentially mirrored) and rebuild my new site, I decided to do it in Rails. That’s the hot take here: just use Rails.

Oh, Yeah, Comments

On my prior site implementation I ran Commento for comments. It was privacy-focused, simple, and easy to work with. Sure, I’d sent them a couple emails over the last four years and never heard back… but surely it’s not also a dead project, right?!

Nope. It’s dead too. Ugh. That’s two architectural choices in 2020 that ended up duds. Lame!

That’s part of why I decided to just built my site in Rails — aside from being totally comfortable for me to work in, it’s also a good long-term bet. 20 years in, it’s sure looking like another 20 years is ahead of us!

Other Options

My first choice was actually to find another bolt-on comments provider. Even though this is a Rails-powered site, I built it super quick, didn’t add a database yet, and didn’t want to sink the time into rolling my own comments stack. So I decided to check out the market and see what other simple, third-party comment systems were available.

There’s not much.

And none of them look great, honestly. Cheap, customizable styling, spam filtering, and simple integration? I couldn’t find anything that does all the above. Ugh.

I Guess I’ll Do It Myself

…I said begrudgingly. The good news is that it turned out to be pretty easy — way faster and simpler than I thought it would be.

Now, I did strip off a few features that I don’t particularly care about, but I now have a full-fledged comment system that’s faster, simpler, easier to work with, and more native to the site than any third-party alternative could offer. I love it. It has full markdown support, even including language-specific code syntax highlighting… but essentially no other frills. And no headaches.

A screenshot of the comment system in action, an example comment
Aw, thanks James Franco!

Let me show you how I did it.

1. Make a Comments Model

Let’s start with a migration. After all, we’re going to be storing these comments in our database! (Make sure you have a database 😅)

We’re only going to track the commenter’s name, email, and comment text (raw markdown text). Like I said, no frills here! We’ll also add a boolean field for whether or not each comment has been identified as spam. And one last column to join the comment to a particular post — it’s a string in my case though. More on that later.

class CreateComments < ActiveRecord::Migration[7.2]
  def change
    create_table :comments do |t|
      t.string :name, null: false
      t.string :email, null: false
      t.text :body, null: false
      t.boolean :spam, null: false
      t.string :post_id, null: false

      t.timestamps
    end

    add_index :comments, :created_at
  end
end

Then an actual Rails model. Not much going on here at all! Just a simple scope for ordering.

class Comment < ApplicationRecord
  scope :oldest_first, -> { order(created_at: :asc) }
end

One thing you might notice that’s missing here is the relationships. Let’s talk about that.

2. Add Relation Spoofs to BlogPost

…or whatever your Page, Post, or thing-that-needs-comments model is called! Since this is a static-content site using Sitepress for all my blog content, it’s a BlogPost object for me. The Sitepress::Model isn’t actually an ActiveRecord object since it’s backed by files in the project, not database records — but that means we can’t use ActiveRecord-style relationships.

The good news is that we sure can spoof them though!

class BlogPost < Sitepress::Model
  collection glob: "blog/*.html*"
  # ...

  def comments
    Comment.where(post_id: request_path, spam: false).oldest_first
  end
end

Yep, it’s really as simple as making an instance method that returns an ActiveRecord query! Now I can write my code as if comments was a real relation — as long as I’m not trying to do complex joins:

@post.comments
# => [#<Comment:0x000012797... ]

And we’ll want to do the same thing on the Comments side for convenience:

class Comment < ApplicationRecord
  scope :oldest_first, -> { order(created_at: :asc) }

  def post
    BlogPost.get(post_id)
  end
end

In this case we’re spoofing the belongs_to relation and just finding the BlogPost object that corresponds to this comment’s post_id

@comment.post
# => #<BlogPost:0x000000012

Note

I mentioned it briefly above, but post_id is actually a string for me, not an integer as one might expect. That’s because Sitepress’ version of an “id” for a file is essentially just its file-path. Since that’s most likely not going to change (even if you change the title of the post), it’s fairly reliable! So my comments look like:

{ # comment attrs
id: 1,
name: "John Doe",
email: "[FILTERED]",
body: "Foo **bar** _baz_ Yay markdown",
spam: false,
post_id: "/blog/simple-comments-for-rails",
created_at: "...",
updated_at: "..."
}

And “join” to their blog post by finding a BlogPost whose ID is /blog/simple-comments-for-rails.

Nice! With our relationships set, we’re ready to dive into the views and controllers (where we’ll use them)!

3. Let’s Talk Views

I’m not going to cover the view layer too much in this post since every site’s markup, styling, and design will be different. But in general, now that we have the relationship together, we can use it in our view markup:

<%# Your blog_posts/show.html.erb or etc %>
<%# Assume you have a @post %>

<% @post.comments.each do |comment| %>
  <%# render each comment %>
    <%= comment.name %>
    <%= comment.created_at %>

    <%# Two options here — if you want just plain text comments: %>
    <%= comment.body %>

    <%# If you want to support markdown content and have a renderer of some kind %>
    <%= ApplicationMarkdown.new.renderer.render(comment.body).html_safe %>
<% end %>

We’ll also need a form to allow guests to make new comments:

<% comment = post.comments.new %>

<%= form_with model: comment do |f| %>
    <%= f.hidden_field :post_id %>

    <%= f.text_field :name, required: true %>
    <%= f.email_field :email, required: true %>
    <%= f.text_area :body, required: true %>
<% end %>

That’s about as bare-bones as it gets, but you get the idea. Some simple markup for a simple Comment object.

4. Controllers ‘Gonna Control

The last piece of the MVC recipe here is a controller. Lucky for us, it’s also incredibly simple. Since comments are rendered into the show view of the Blog post (or whatever your resource is), we don’t need a controller to render comments. We just need one to receive POSTs with new comments. That looks like this:

class CommentsController < ApplicationController
  # POST /comments
  def create
    post = BlogPost.find(params[:comment][:post_id])

    comment = post.comments.create!(comment_params)

    redirect_to post.request_path, status: :see_other
  end

  private def comment_params
    params.require(:comment).permit(:name, :email, :body)
  end
end

Create a comment and push the user back to the path of the blog post they sent it from! It’s that simple.

Spammers ‘Gonna Spam

What we’ve just built is a comment system whose only validation and gating is marking the fields required in the HTML. It does work, and allows anybody to comment as long as they put something in those fields but.. it’s not great, security-wise. Spam comments hit public forms all the time. The volume of garbage that hits our forms is wild. So how do we deal with spam while keeping this whole setup extremely simple and minimal?

We could install hCaptcha or Cloudflare Turnstile, each doing their own styles of javascript-driven or human-interaction-driven ‘tests’ to suss out whether or not the user is a real person. But integration those systems takes a little time and effort.. and this is just a personal site!

We could implement a honey-pot sort of simple HTML/value traps, but those tend to be solved and cleared by a lot of the smarter spam bots these days. I don’t think they’re very reliable.

We could even go so far as to implement user authentication into the app and force folks to make an account and log in before they can leave a comment! Aside from knowing that nobody wants to create an account on my website just to leave a comment, that also sounds like a ton of time and work. I don’t need Devise! It’s a personal site! Calm down!

So what shall we do?

Well… we know we need some kind of spam filter. We don’t want spam comments! But what actually differentiates spam comments from normal comments? A lot of these tools ensure that the writer is human, not a bot. But that doesn’t necessarily stop spam — humans can write spam too!

What if we actually assess the content of the comment to determine if it’s spammy? Humans are really good at that! We have the ability to discern between real and spam almost immediately. Look, is this comment spam?

Hey Jon. Thanks a lot for taking the time to go deep on this. These are the pieces that help everyone out when the documentation is still not there.

Just to point something out, I think you might have missed a \“not\” in the… [truncated]

You could probably tell within the first sentence or two that this comment was real and not spam at all!

What about this one, though?

Hi jonsully.net Administrator. I just found your site, quick question…

My name’s Eric, I found jonsully.net after doing a quick search – you showed up near the top of the rankings, so whatever you’re doing for SEO, looks… [truncated]

🥱 Yeah, classic spam. I didn’t even have to read past the first line.

The point I’m getting at is that we can quickly identify spam comments purely based on their content and tone. We have a great sense of what’s real and what’s not.

AI can do that too.

Let me show you my actual CommentsController:

class CommentsController < ApplicationController
  # POST /comments
  def create
    post = BlogPost.find(params[:comment][:post_id])

    comment = post.comments.new(comment_params)
    comment.spam = OpenAi.comment_is_spam?(comment.name, comment.email, comment.body, post.body)
    comment.save!

    redirect_to post.request_path, status: :see_other
  end

Let OpenAi Deal With It

While I’m not going to share my prompt here publicly since it could expose prompt vulnerabilities, the point is simply this: AI alone can filter out spam comments quickly, easily, and with an insane accuracy. I never recommend executing an HTTP request while handling an incoming HTTP request, but with how fast the 4o-mini model is and the nature of comments being posted rarely on my site, I’m okay with the short delay. Here’s the code that wraps that all up:

class OpenAi
  MODEL_DEFAULT = "gpt-4o-mini"

  def self.comment_is_spam?(name, email, body, post_body)
    return false if Rails.env.development?

    sys_message = <<-EOF
      You are a comment validation bot...
    EOF

    user_message = <<-EOF
      Someone wants to leave a comment on the post...
    EOF

    client = OpenAI::Client.new
    response = client.chat(
      parameters: {
        model: MODEL_DEFAULT,
        response_format: {
          type: "json_schema",
          json_schema: {
            name: "comment_response",
            schema: {
              type: "object",
              properties: {
                is_spam: {type: "boolean"}
              },
              required: ["is_spam"],
              additionalProperties: false
            },
            strict: true
          }
        },
        messages: [
          {role: :system, content: sys_message},
          {role: :user, content: user_message}
        ],
        functions: nil
      }
    )
    message = response.dig("choices", 0, "message", "content")
    JSON.parse(message).dig("is_spam")
  end
end

And I know the big hash of crazy looking stuff in the middle looks scary, but it’s actually quite neat. All it does is force the model to respond in a specified JSON schema format. Which, here, is essentially saying: you must respond as either

{
  "is_spam": true
}
// or
{
  "is_spam": false
}

And leaves the model no choice but to do so. How cool is that!? Thanks to structured output, we can now code against the responses with certainty that they’ll be in the format / shape we want. Amazing.

Overall, the advantage here is how wildly easy this is. We’re just running each comment through AI and asking, “Is it spam?” That’s it! All the other spam detection / mitigation options take significantly more time and effort to build. This one? Deployed and running in a couple of minutes.

Now, just change the Post markup to only show comments that are spam = false and boom, a fully spam-proof blog comment system.

Features, huh?

I’ll wrap up this quick post with a feature round-up.

  • What this comment system doesn’t do:
    • Send emails! It could but .. eh. Maybe in the future. ActionMailer is a thing! This really only matters for telling someone when I reply to their comment. Maybe I’ll do that manually. Who knows!
    • Thread comments. Didn’t want to deal with the parent/child relationships.. and also having to send follow-up emails.
    • Have a moderation dashboard. I have a Rails console! Not worth it yet.
  • What this comment system does do:
    • Look great. Significantly better than my old comment system! And since it’s actually part of this site, it matches the styling and theme!
    • Support markdown. I’m big on Markdown and this site is powered (mostly) by Markdown. My comments must support Markdown! It was fairly easy to execute the same Redcarpet setup already being used in Sitepress.
    • Have a low barrier to entry. There’s no account to make, no boxes to check. I require an email address, but it’s not used anywhere — simply put in the database in case I need it later. In fact, the validations are so lax that you can put “Anonymous” in the name field and “[email protected]” in the email field. As long as the message in the comment field itself is compelling enough to not look like spam (according to OpenAi), it’ll fly!
    • Run incredibly fast. Since it’s just a tiny query with a tiny bit of markup, painting and rendering comments is insanely fast — far faster than any third party bolt-on system could be! Even fast JS libraries loaded over the wire will never beat quick server-side rendered stuff.
    • Turbo Drive helps too. Page Refreshes and Morphing kick in when posting a comment for a beautiful UX there too.
    • Ping me when there’s a comment. I didn’t build out email but I do want to know when someone leaves a (non-spam-) comment. That’s as simple as a Slack web-hook! I’ll leave this as an exercise for the reader.

So I suppose the end-goal here was to build a simple comments system that’s fuss-free, headache-free, easy to understand, easy to work on (if needed), and generally just simple. I think this build does that very well!

Of course, what do you think? This would be a perfect time for you to… leave a comment 😉


P.S. I also activated Turbo Page Refreshes and Morphing so that actually adding a comment feels like a seamless PWA experience. It just pops right in (assuming you’re not spam) like it was all Javascript-driven. It only took two lines of code to add that! In fact, if I add a third line, it’ll pop in for everyone else looking at the page at the same time, too!

I wrote a nice long post on how all the Turbo 8 / Morphing stuff works here and recommend you check it out! The stuff is crazy cool and powerful:

Comments? Thoughts?

Jon Sully (Author)

I’d love to hear what you think!

Feel free to leave a comment below and let me know. No account to sign up for, no barrier to entry; just an open markdown field for you to type your own thoughts into!


P.S. Have you played with OpenAi’s new Structured Output? This system runs on 4o-mini and being able to guarantee that the output will be the type I expect is fantastic!

Jordan Peters

This approach is refreshingly simple and avoids over-complication!

I do have a detailed question, though: I noticed in your Comment model that you’re using BlogPost.get(post_id) to retrieve the post. Is there a specific reason for choosing this method over the standard Rails association (belongs_to :post)? It seems like Rails’ associations could handle this more seamlessly. Just curious if there are any trade-offs or specific scenarios where this method provides a clear advantage!

Jon Sully (Author)

Hey Jordan!

That’s a great question and a good eye. Since I’m using Sitepress to handle and serve all of my site’s static content (markdown blog posts, etc.), I interface with those static files via a wrapping model. BlogPost, in this case:

class BlogPost < Sitepress::Model
  collection glob: "blog/*.html*"
  # ...
  # ...
end

And while that’s a super nice abstraction and works really well, that object is not an ActiveRecord model. A BlogPost inherits from Sitepress’ “model” (< Sitepress::Model) and, at the end of the day, is just a Plain Old Ruby Object. And you can’t have_many to a PORO!

So, on both sides, I essentially use instance methods to act like the ActiveRecord associations would — cloning the ergonomics and patterns. It’s working great here!

Reply

Please note: spam comments happen a lot. All submitted comments are run through OpenAI to detect and block spam.