
Short answer: A Go RSS feed is one HTTP handler. Use the gorilla/feeds package to build a feeds.Feed from your latest published posts with absolute links, stable IDs and UTC times, call ToRss(), and serve the result with the application/rss+xml content type through http.ServeContent so conditional requests work. Cache it for a few minutes, validate it once, and any RSS auto-poster can share every new article automatically.
Go is popular for fast, low-maintenance web services: company sites, developer blogs, documentation portals, status pages and content APIs that run for years on a single binary. The standard library gives you an excellent HTTP server and XML encoder, but no ready-made feed. Adding one is worth the hour it takes. A feed is how readers subscribe, how newsletters and aggregators find your newest content, and how social automation tools publish each article the moment it goes live. This guide shows a reliable way to build the feed in Go, the details that most often break it, and how to connect it to social auto-posting.
Agree on the requirements first. A dependable feed:
Images and categories are optional, but they shape the social posts built from the feed, so we add them once the basic version validates.
The gorilla/feeds package builds RSS 2.0, Atom and JSON Feed from one set of Go structs. It handles XML escaping and date formatting, which are the usual weak points of hand-built feeds. Add it to your module:
go get github.com/gorilla/feedsA handler that builds the feed looks like this. It assumes a function latestPosts(ctx, 30) that returns published posts whose publication time has passed, newest first, with Title, Slug, Summary oraz PublishedAt fields. The public base URL comes from configuration.
package main
import (
"bytes"
"net/http"
"time"
"github.com/gorilla/feeds"
)
const baseURL = "https://example.com"
func rssHandler(w http.ResponseWriter, r *http.Request) {
posts, err := latestPosts(r.Context(), 30)
if err != nil {
http.Error(w, "feed unavailable", http.StatusInternalServerError)
return
}
updated := time.Now().UTC()
if len(posts) > 0 {
updated = posts[0].PublishedAt.UTC()
}
feed := &feeds.Feed{
Title: "Example Blog",
Link: &feeds.Link{Href: baseURL + "/"},
Description: "New articles from Example",
Created: updated,
}
for _, p := range posts {
url := baseURL + "/blog/" + p.Slug
feed.Items = append(feed.Items, &feeds.Item{
Title: p.Title,
Link: &feeds.Link{Href: url},
Id: url,
Description: p.Summary,
Created: p.PublishedAt.UTC(),
})
}
rss, err := feed.ToRss()
if err != nil {
http.Error(w, "feed unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300")
http.ServeContent(w, r, "rss.xml", updated, bytes.NewReader([]byte(rss)))
}Several choices matter. The item Id is the permanent article URL and becomes the item’s guid; it should never change after publication. The feed’s modification time follows the newest post rather than the current time, so it does not look changed on every request. Errors stop the handler before any XML is written, so a failed query never produces a half-finished document. And http.ServeContent takes care of If-Modified-Since requests, answering 304 Not Modified when nothing is new.
If you prefer no dependency, the standard encoding/xml package is enough. Define structs for the channel and items with XML tags such as xml:"title" oraz xml:"guid", marshal them with xml.MarshalIndent, and write xml.Header before the result so the document starts with the XML declaration. The encoder escapes text automatically.
The one thing to do by hand is the date. Format publication times with t.UTC().Format(time.RFC1123Z), which produces the numeric-offset form RSS readers accept. Do not use time.RFC3339 in an RSS feed; that format belongs to Atom. The standard library approach gives you full control over unusual elements; gorilla/feeds is quicker when you want RSS and Atom from the same data. The net/http documentation for ServeContent explains how either version can serve conditional responses.
Go is strict about types, but feeds still go wrong in predictable places.
time.Time read from the database may carry the server’s local zone or no meaningful zone at all, depending on the driver settings. Normalise with .UTC() before building items, and configure the driver to parse times, for example with parseTime=true&loc=UTC for MySQL.Our guides to RSS date format errors oraz time zones and daylight saving in RSS auto-posting cover these problems in more depth.
The example uses a fixed baseURL, which is the safest option. Go’s HTTP server does not rewrite requests based on proxy headers, so if you build links from r.Host oraz r.TLS behind Nginx, Caddy or a load balancer, you will often get the internal host or plain HTTP.
X-Forwarded-Proto oraz X-Forwarded-Host only when the request really comes from your proxy.http://, localhost, :8080 and internal host names.Relative links cause the same trouble from the other direction: they work in templates but break once an item is read elsewhere, as explained in our article on relative URLs and broken links in RSS feeds.
Richer items produce better social posts. When the basic feed validates, add:
Enclosure field for a URL, length and MIME type. Use an absolute URL to a mid-sized picture; many auto-posting tools use it as the post image.category element per tag.og:title, og:description oraz og:image, because tools often fall back to them.html/template layout, add <link rel="alternate" type="application/rss+xml" title="Example Blog" href="https://example.com/rss.xml"> so browsers and tools can find the feed.Because one feeds.Feed value can be written as RSS, Atom or JSON Feed, extra formats are almost free. Move the building code into a function such as buildFeed(posts, title), then add handlers for /atom.xml using ToAtom() with application/atom+xml, and /feed.json using ToJSON() if you want it.
Category feeds use the same function. With the Go 1.22 router you can register mux.HandleFunc("GET /category/{slug}/rss.xml", ...) and read the slug with r.PathValue("slug"). Return 404 for unknown categories, give each feed a descriptive title, and keep every article’s ID identical across feeds so tools that follow several feeds see one item. If duplicates still appear, the guide on duplicate GUID fixes explains how auto-posters decide what is new.
Feeds are polled constantly by readers, aggregators and auto-posting services, often every few minutes. Go is fast, but each request still means a database query.
http.ServeContent already handles If-Modified-Since. You can also set an ETag header before calling it, and it will honour If-None-Match as well.sync.RWMutex, rebuild when they are older than a few minutes, and reset the cache when a post is published.Long cache lifetimes delay auto-posting, so keep them short. Our explanation of why cached feeds make auto-posts lag shows how the delays add up.
A table-driven test with httptest.NewRecorder protects the feed. Call the handler with a request for /rss.xml, check status 200 and the content type, unmarshal the body with encoding/xml into a small struct and assert that the first item has a title, an absolute link and a parsable pubDate. Run it with go test in CI so a refactor cannot silently break the feed.
| Problem | Typical cause in Go | Fix |
|---|---|---|
| Links use http or an internal host | URLs built from r.Host behind a proxy | Use a configured base URL |
| Dates shifted by hours | Driver returns local or zone-less times | Parse as UTC and call .UTC() |
| Validator rejects pubDate | RFC3339 used in an RSS feed | Format with time.RFC1123Z |
| Missing XML declaration | xml.Marshal without xml.Header | Write xml.Header first |
| Same post shared twice | Item ID built from an editable title | Use the permanent URL as the ID |
| Feed always looks changed | Modification time set to time.Now() | Use the newest post’s time |
Before connecting automation, fetch the headers with curl -I, expect HTTP 200 without redirects, validate the feed and open a few item links in a private window to confirm they are public.
With a valid feed in place, sharing no longer depends on someone remembering to post. Add the feed URL to an auto-posting service, connect your social accounts and channels, decide how posts are built from the title, summary and link, and choose posting hours if needed. The service checks the feed and publishes each new item once.
Go teams often route content by audience: engineering posts to Mastodon or Bluesky, product updates to a professional network, release notes and incident updates to a Telegram channel, a Discord server or a team chat. Category feeds make each route a matter of choosing the right feed. More setups are collected under technical guides and the Kanał RSS topic.
PostRSS reads any RSS or Atom feed, including one served by a Go handler, and publishes each new item to the networks you connect. It checks feeds as often as every minute, takes the image from the feed item or the page’s Open Graph tags, and offers keyword filters, hashtags from item categories and UTM parameters. Developers can also use the PostRSS REST API v1 and outgoing webhooks, and the service has been running since 2014. See the features page for all 66 networks and the pricing page for what each plan includes.
A Go RSS feed needs one handler and a few careful choices: gorilla/feeds or encoding/xml for safe XML, a configured HTTPS base URL, UTC times in the right format, permanent item IDs, the correct content type, early error returns and a short cache served through http.ServeContent. Add images and categories, offer Atom or JSON Feed from the same data if you like, cover the handler with an httptest check and validate it once. After that the feed runs quietly for years, and every new article reaches subscribers and your social channels without manual sharing.
There is no feed package in the standard library, but encoding/xml and net/http are enough to build one. The gorilla/feeds package makes it shorter.
Use an RFC 822 style date. In Go, format UTC times with time.RFC1123Z. RFC3339 is the format for Atom feeds, not RSS.
Serve the generated bytes with http.ServeContent and pass the newest post’s time. It answers If-Modified-Since requests, and an ETag header you set is honoured too.
Yes. A gorilla/feeds Feed value can be written with ToRss, ToAtom or ToJSON, so one builder function can serve several formats.
Yes. Once the feed is public and valid, an RSS auto-posting service can watch it and publish each new article to the social networks and channels you connect.
What changed in the networks, what broke, and how to fix it before it costs you reach.
Tworzy je Internet Solutions, zespół stojący za PostRSS. Każdy produkt oszczędza czas na swój sposób.