※ Links are at the bottom of the post.

Elfeed is an Emacs package for experiencing one’s RSS feeds. There are others. I leave it as an exercise for the reader to learn more.

(use-package elfeed
  :custom
  ;; (elfeed-db-directory (expand-file-name "elfeed" user-emacs-directory)) ; uncomment this line and 
  (elfeed-db-directory "~/plrjorg/elfeed") ; comment this line if you want elfeed to write to your .emacs.d
  (elfeed-enclosure-default-dir "~/plrjorg/Downloads/") ; or wherever you want downloads to go
  (elfeed-search-remain-on-entry t)
  (elfeed-search-title-max-width 100)
  (elfeed-search-title-min-width 30)
  (elfeed-search-trailing-width 25)
  (elfeed-show-truncate-long-urls t)
  (elfeed-sort-order 'descending)
  :bind
  ("C-c w e" . bjm/elfeed-load-db-and-open)
  (:map elfeed-search-mode-map
        ("w" . elfeed-search-yank)
        ("R" . elfeed-update)
        ("q" . bjm/elfeed-save-db-and-bury)
        ("Q" . elfeed-kill-buffer)
        )
  (:map elfeed-show-mode-map
        ("S"     . elfeed-show-new-live-search) ; moved to free up 's'
        ("c"     . (lambda () (interactive) (org-capture nil "capture")))
        ("e"     . email-elfeed-entry)
        ("f"     . elfeed-show-fetch-full-text)
        ("w"     . elfeed-show-yank)
        )
  :hook
  (elfeed-show-mode . visual-line-mode) ; make reading pretty
  (elfeed-show-mode . olivetti-mode   ) ; make reading pretty
  )

elfeed-org helps me with adding tags to my feeds. Need refinement as it’s not doing exactly what I want at the moment.

;; Configure Elfeed with org mode

(use-package elfeed-org
  :after elfeed
  :custom
  (rmh-elfeed-org-files
   (list "~/.emacs.d/elfeed.org"))
)

(with-eval-after-load 'elfeed
  (elfeed-org))

~/plrjorg/ is my Nextcloud Files directory. I use it for synchronizing things, including elfeed.

When we talk about syncing elfeed, there are two components: feed sync and database sync. They are independent of each other. One does not need either.

First is feed synchronization. There are many platforms that provide a protocol to do this, and some of them will work with elfeed et al. See elfeed-protocol for what’s supported. One can chose to not use a protocol and put their feeds in their Emacs config or in an elfeed-org file (see below). One can then copy them to other Emacsen one may have.

Second is database synchronization, which is specific to elfeed and Emacs. This is only sort-of supported by elfeed. I use a combination of some functions and Nextcloud sync to make this part happen, but the reader can find articles about using syncthing or other tools.

WARNING!!! Not all modern file sync methods are appropriate for this! iCloud, Box, Synology Drive, and others don’t really like permanently keeping files on a system. While one can mark a folder as “Make available offline” or similar, it may still cause problems. I use Nextcloud because it works like Dropbox used to (still does? I dunno) and doesn’t rely on 🍎’s or M$’s newer APIs. Again, feel free to search for more information as my understanding may be faulty &| out of date.

NOTE Nextcloud sync is not a perfect solution out of the box. The client wants to notify about every. little. thing., so one will want to mute all but urgent notifications. Also, while Nextcloud has some version control for files, it is far from robust. Backups are still a must.

I like the modern convenience of syncing state in and out of Emacs, so I make use of elfeed-protocol. I’ve used it to connect to a Nextcloud News install, but currently point it at my self-hosted FreshRSS install. For the sake of this article, it’s url is http://freshrss.example so edit that as needed. Eventually I will move the authentication into .authinfo.gpg where it belongs.

(use-package elfeed-protocol
  :after (elfeed elfeed-org)
  :bind
  (:map elfeed-show-mode-map
        ("s" . elfeed-protocol-fever-star-tag)
        )
  :custom
  (elfeed-use-curl t)			; My FreshRSS wants curl
  (elfeed-curl-extra-arguments '("--insecure"))
  (elfeed-curl-max-connections 10)
  (elfeed-protocol-feeds '(("fever+http://[email protected]/" :api-url "http://freshrss.example/api/fever.php" :password "somecoollongpassword")))
  (elfeed-protocol-fever-fetch-category-as-tag t)
  (elfeed-protocol-fever-maxsize 10000)
  (elfeed-protocol-fever-update-unread-only t)
  (elfeed-protocol-lazy-sync t)
  (elfeed-set-timeout 36000)
  ;; (elfeed-log-level 'debug)  ; for debugging when needed
  ;; (elfeed-protocol-fever-maxsize 10)  ; for debugging when needed
  ;; :hook
  ;; (after-init . toggle-debug-on-error)   ; for debugging when needed
  )

(with-eval-after-load 'elfeed
  (elfeed-protocol-enable))

I’ll take this moment to describe the with-eval-after-load items. Basically, these packages don’t load quickly as a whole. I don’t need them until I actually do something with elfeed. My config essentially keeps them hanging around until I do do something with elfeed, then they load.

I don’t kill my Emacs often. As such I don’t care a lot about load times. But I do have a geriatric Intel MacBook Air I use when and where I don’t want to take my fancy M3 MBA. For her sake and my eventual foray into Emacs on Android I will care a little bit. Also, Dr Peter Prevos and Bozhidar Batsov put me on to how to improve my use of use-package in this regard, but it’s outside of the scope of this article.

Again, for the sake of completeness, here is the rest of my elfeed config:

(use-package elfeed-tube
  :after elfeed
  :bind (:map elfeed-show-mode-map
         ("F" . elfeed-tube-fetch)
         ([remap save-buffer] . elfeed-tube-save)
         :map elfeed-search-mode-map
         ("F" . elfeed-tube-fetch)
         ([remap save-buffer] . elfeed-tube-save)
         )
  ;; :config
  ;; (setq elfeed-tube-auto-save-p nil) ; default value
  ;; (setq elfeed-tube-auto-fetch-p t)  ; default value
  ;; (elfeed-tube-setup)
  ;; :hook
  ;; (after-init . elfeed-tube-setup)
  )

(with-eval-after-load 'elfeed-tube
  (elfeed-tube-setup))

(use-package elfeed-tube-mpv
  :bind (:map elfeed-show-mode-map
              ("C-c C-f" . elfeed-tube-mpv-follow-mode)
              ("C-c C-w" . elfeed-tube-mpv-where)
              )
  )

(use-package elfeed-webkit
  :after elfeed
  :bind (:map elfeed-show-mode-map
              ("%" . elfeed-webkit-toggle)
              )
  )

(with-eval-after-load 'elfeed
  (elfeed-webkit-auto-toggle-by-tag))

(use-package elfeed-curate
  :after elfeed
  :bind
  (:map elfeed-search-mode-map
        ("a" . elfeed-curate-edit-entry-annoation)
        ("x" . elfeed-curate-export-entries)
        )
  (:map elfeed-show-mode-map
        ("a" . elfeed-curate-edit-entry-annoation)
        ("m" . my/elfeed-curate-toggle-star)
        )
  )

;; Easy insertion of weblinks

(use-package org-web-tools
  :bind
  (("C-c w w" . org-web-tools-insert-link-for-url)))

;; Internet

(use-package emacs
  :custom
  ;; (shr-fill-text nil)			; Emacs 30.1+
  (url-queue-parallel-processes 10)
  (url-queue-timeout 30)
  :ensure nil
  )

Note that not all of these bits are working as I’d like. elfeed-webkit works but needs refining in my config, as does elfeed-org.

This is where things get dodgier: my functions, most of which I copied from an older config. I might not be using them all, especially as elfeed has seen it’s own refinement over the years.

First are the functions I’m using to sync:

(defun elfeed-protocol-fever-sync-unread-stat (host-url)
  "Set all entries in search view to read and fetch latest unread entries.
HOST-URL is the host name of Fever server with user field authentication info,
for example \"https://[email protected]\".

Author: Andrey Listopadov
URL https://github.com/fasheng/elfeed-protocol/issues/71#issuecomment-2483697511
Created: 2024-11-18
Updated: 2025-06-12"
  (interactive
   (list (completing-read
          "feed: "
          (mapcar (lambda (fd)
                    (string-trim-left (car fd) "[^+]*\\+"))
                  elfeed-protocol-feeds))))
  (save-mark-and-excursion
    (mark-whole-buffer)
    (cl-loop for entry in (elfeed-search-selected)
             do (elfeed-untag-1 entry 'unread))
    (elfeed-protocol-fever--do-update host-url 'update-unread))
  )

;;functions to support syncing .elfeed between machines
;;makes sure elfeed reads index from disk before launching
(defun bjm/elfeed-load-db-and-open ()
  "Wrapper to load the elfeed db from disk before opening

URL https://pragmaticemacs.wordpress.com/2016/08/17/read-your-rss-feeds-in-emacs-with-elfeed/
Created: 2016-08-17
Updated: 2025-06-13"
  (interactive)
  (elfeed)
  (elfeed-db-load)
  ;; (elfeed-search-update--force)
  (elfeed-update)
  (elfeed-db-save)
  )

;;write to disk when quiting
(defun bjm/elfeed-save-db-and-bury ()
  "Wrapper to save the elfeed db to disk before burying buffer

URL https://pragmaticemacs.wordpress.com/2016/08/17/read-your-rss-feeds-in-emacs-with-elfeed/
Created: 2016-08-17
Updated: 2025-06-13"
  (interactive)
  (elfeed-update)
  (elfeed-db-save)
  (quit-window))

These are the rest.

;; Elfeed functions

(org-link-set-parameters "elfeed"
  :follow #'elfeed-link-open
  :store  #'elfeed-link-store-link
  :export #'elfeed-link-export-link)

(defun elfeed-link-export-link (link desc format _protocol)
  "Export `org-mode' `elfeed' LINK with DESC for FORMAT.

Author: Jeremy Friesen
URL https://takeonrules.com/2024/08/11/exporting-org-mode-elfeed-links/
Created: 2024-08-11
Updated:20205-06-10"
  (if (string-match "\\([^#]+\\)#\\(.+\\)" link)
    (if-let* ((entry
                (elfeed-db-get-entry
                  (cons (match-string 1 link)
                    (match-string 2 link))))
               (url
                 (elfeed-entry-link entry))
               (title
                 (elfeed-entry-title entry)))
      (pcase format
        ('html (format "<a href=\"%s\">%s</a>" url desc))
        ('md (format "[%s](%s)" desc url))
        ('latex (format "\\href{%s}{%s}" url desc))
        ('texinfo (format "@uref{%s,%s}" url desc))
        (_ (format "%s (%s)" desc url)))
      (format "%s (%s)" desc url))
    (format "%s (%s)" desc link)))

;;
;; I think elfeed handles buffers and the scrolling better now, so disable these.
;;
;; (defun elfeed-display-buffer (buf &optional act)
;;   "URL https://karthinks.com/software/lazy-elfeed/"
;;   (pop-to-buffer buf)
;;   (elfeed-show-refresh)
;;   (set-window-text-height (get-buffer-window) (round (* 0.7 (frame-height))))
;;   (elfeed-show-refresh)
;;   )

;;   (defun elfeed-search-show-entry-pre (&optional lines)
;;   "Returns a function to scroll forward or back in the Elfeed
;;   search results, displaying entries without switching to them."
;;       (lambda (times)
;;         (interactive "p")
;;         (forward-line (* times (or lines 0)))
;;         (recenter)
;;         (call-interactively #'elfeed-search-show-entry)
;;         (select-window (previous-window))
;;         (unless elfeed-search-remain-on-entry (forward-line -1))))

;; (defun elfeed-scroll-up-command (&optional arg)
;;   "Scroll up or go to next feed item in Elfeed"
;;   (interactive "^P")
;;   (let ((scroll-error-top-bottom nil))
;;     (condition-case-unless-debug nil
;;         (scroll-up-command arg)
;;       (error (elfeed-show-next)))))

;; (defun elfeed-scroll-down-command (&optional arg)
;;   "Scroll up or go to next feed item in Elfeed"
;;   (interactive "^P")
;;   (let ((scroll-error-top-bottom nil))
;;     (condition-case-unless-debug nil
;;         (scroll-down-command arg)
;;       (error (elfeed-show-prev)))))

(defun elfeed-tag-selection-as (mytag)
    "Returns a function that tags an elfeed entry or selection as
MYTAG"
    (lambda ()
      "Toggle a tag on an Elfeed search selection"
      (interactive)
      (elfeed-search-toggle-all mytag)))

(defun elfeed-show-eww-open (&optional use-generic-p)
  "open with eww"
  (interactive "P")
  (let ((browse-url-browser-function #'eww-browse-url))
    (elfeed-show-visit use-generic-p)))

(defun elfeed-search-eww-open (&optional use-generic-p)
  "open with eww"
  (interactive "P")
  (let ((browse-url-browser-function #'eww-browse-url))
    (elfeed-search-browse-url use-generic-p)))

(defun browse-url-mpv (url &optional single)
  (start-process "mpv" nil "mpv" (shell-quote-argument url)))

(defun email-elfeed-entry ()
  "Capture the elfeed entry and put it in an email.

URL https://github.com/jkitchin/scimax/blob/master/scimax-elfeed.el
Created: 2021-11-16
Updated: 2025-06-10"
  (interactive)
  (let* ((title (elfeed-entry-title elfeed-show-entry))
         (url (elfeed-entry-link elfeed-show-entry))
         (content (elfeed-entry-content elfeed-show-entry))
         (entry-id (elfeed-entry-id elfeed-show-entry))
         (entry-link (elfeed-entry-link elfeed-show-entry))
         (entry-id-str (concat (car entry-id)
                               "|"
                               (cdr entry-id)
                               "|"
                               url)))
    (compose-mail)
    (message-goto-subject)
    (insert title)
    (message-goto-body)
    (insert (format "You may find this interesting:
%s\n\n" url))
    (insert (elfeed-deref content))

    (message-goto-body)
    (while (re-search-forward "<br>" nil t)
      (replace-match "\n\n"))

    (message-goto-body)
    (while (re-search-forward "<.*?>" nil t)
      (replace-match ""))

    (message-goto-body)
    (fill-region (point) (point-max))

    (message-goto-to)
    (ivy-contacts nil)))

;; * store links to elfeed entries
;; These are copied from org-elfeed
(defun org-elfeed-open (path)
  "Open an elfeed link to PATH."
  (cond
   ((string-match "^entry-id:\\(.+\\)" path)
    (let* ((entry-id-str (substring-no-properties (match-string 1 path)))
           (parts (split-string entry-id-str "|"))
           (feed-id-str (car parts))
           (entry-part-str (cadr parts))
           (entry-id (cons feed-id-str entry-part-str))
           (entry (elfeed-db-get-entry entry-id)))
      (elfeed-show-entry entry)))
   (t (error "%s %s" "elfeed: Unrecognised link type - " path))))

(defun org-elfeed-store-link ()
  "Store a link to an elfeed entry."
  (interactive)
  (cond
   ((eq major-mode 'elfeed-show-mode)
    (let* ((title (elfeed-entry-title elfeed-show-entry))
           (url (elfeed-entry-link elfeed-show-entry))
           (entry-id (elfeed-entry-id elfeed-show-entry))
           (entry-id-str (concat (car entry-id)
                                 "|"
                                 (cdr entry-id)
                                 "|"
                                 url))
           (org-link (concat "elfeed:entry-id:" entry-id-str)))
      (org-link-store-props
       :description title
       :type "elfeed"
       :link org-link
       :url url
       :entry-id entry-id)
      org-link))
   (t nil)))

;; Duplicate of above!
;;  ⮯⮮
;; (org-link-set-parameters
;;  "elfeed"
;;  :follow 'org-elfeed-open
;;  :store 'org-elfeed-store-link)

;; my/elfeed-show-entry-advice: ensures we store the entry and refresh the article after it has been rendered. The run-at-time trick delays just enough to avoid incomplete rendering.
;;
;; my/elfeed-refresh-on-resize: triggers refresh if the window is resized and you're currently in elfeed-show-mode.
;;
;; It tracks the current entry so you don’t get errors from nil.

(defvar my/elfeed-current-entry nil
  "Currently displayed Elfeed entry (used to trigger refresh on resize).")

(defun my/elfeed-show-entry-advice (entry)
  "Store the currently shown ENTRY and reflow after display."
  (setq my/elfeed-current-entry entry)
  ;; Run refresh a bit later so it's sure the buffer is fully rendered
  (run-at-time "0.05 sec" nil
               (lambda ()
                 (when (eq major-mode 'elfeed-show-mode)
                   (elfeed-show-refresh)))))

(advice-add 'elfeed-show-entry :after #'my/elfeed-show-entry-advice)

(defun my/elfeed-refresh-on-resize (_frame)
  "Reflow Elfeed article when the window is resized."
  (when (and (eq major-mode 'elfeed-show-mode)
             my/elfeed-current-entry)
    ;; this ensures we re-render the article on resize
    (elfeed-show-refresh)))

(add-hook 'window-size-change-functions #'my/elfeed-refresh-on-resize)

(defun elfeed-show-fetch-full-text ()
  "Fetch the full text of the current Elfeed entry using eww-readable."
  (interactive)
  (let* ((entry elfeed-show-entry)
         (url (elfeed-entry-link entry)))
    (eww url)  ;; Open the URL in eww
    (run-at-time "0.5 sec" nil
                 (lambda ()
                   (with-current-buffer "*eww*"
                     (eww-readable))))))  ;; Call eww-readable after a short delay

(defvar elfeed-curate-exit-keys "C-x C-s"
  "Save the content from the recursive edit buffer to an entry annotation."
  )

(defun my/elfeed-curate-toggle-star ()
  "Wrapper to refresh the entry to see the star tag in the entry."
  (interactive)
  (elfeed-curate-toggle-star)
  (elfeed-show-refresh)
  )

(defun elfeed-curate-edit-annotation (title default-string)
  "Edit annotation string for the group TITLE with the DEFAULT-STRING.
  Returns the annotation buffer content."
  (with-temp-buffer
    (org-mode)
    (setq buffer-read-only nil)
    ;; (setq mode-line-format nil)
    (outline-show-all)
    (rename-buffer elfeed-curate-capture-buffer-name t)
    (insert default-string)
    (goto-char (point-max))
    (let ((title-str (propertize (concat " '" (elfeed-curate-truncate-string title elfeed-curate-title-length) "'")
                                 'face 'mode-line-buffer-id)))
      (setq header-line-format
            (list
             (elfeed-curate--key-emphasis "Annotate")
             title-str
             " --> Save: '" (elfeed-curate--key-emphasis elfeed-curate-exit-keys)
             "', Delete: '" (elfeed-curate--key-emphasis elfeed-curate-delete-keys)
             "', Abort: '"  (elfeed-curate--key-emphasis elfeed-curate-abort-keys)
             "'")))
    (switch-to-buffer (current-buffer))
    (use-local-map (elfeed-curate--annotation-keymap))
    (font-lock-mode)
    (recursive-edit)
    (buffer-substring-no-properties (point-min) (point-max))
    )
  )

The main dependency outside of Emacs is curl. For the elfeed-tube stuff, mpv and yt-dlp. Check the relevant documentation.