There are several packages which have a switch-to-buffer-like command that also suggests recent files in addition to existing buffers. The oldest one it probably the built-in ido.el with its concept of "virtual buffers" you can enable with (setq ido-use-virtual-buffers t). The consult package's consult-buffer commands even go a step further and provide even more sources for "virtual buffers" next to recent files, e.g., bookmarks, registers, project files, or home-brewn sources.

I've used ido.el back in the days and over multiple intermediate steps settled on the typical and awesome completion combo of vertico, corfu, and marginalia. I've also tried consult for some time but its previews, i.e., that it immediately shows the completion candidate in a buffer, is a bit too dynamic for my taste. Same for its search capabilities like consult-ripgrep where I prefer a more static UI like that of rg. That said, what I've immediately missed after removing the consult package is the support for recent files in the (now again stock) switch-to-buffer command. I'm so used to switching to virtual buffers; I never visit files in projects I'm working using C-x C-f because C-x b always worked, too. I never had to ask myself "did I already find that file in this emacs session?" and then choose the right command, i.e., find-file or switch-to-buffer.

However, reading the docs showed that since ages (Emacs 20.3) one can define a custom read-buffer-function which is then used by read-buffer, the internal workhorse of the commands switch-to-buffer / pop-to-buffer. So the question is how to sneak in recent files in addition to actual live buffers. The answer for me was to use a completion table which tries to do buffer completion and if that runs out of candidates, switch to a completion table for recent files. Such a "try this and if it won't work, try that" completion table can be composed from existing tables using completion-table-in-turn. That works great for me but won't do the trick if you have a buffer foo.txt and also a different recent file foo.txt. In such a case, when you rather want a completion table composed of other tables which should have the same priority, you can use completion-table-merge instead of completion-table-in-turn.

Long story short, here's the code which I'm using now:

(defconst th/read-buffer-or-recentf-command-alist
  '((kill-buffer buffers)
    (switch-to-buffer buffers-except recentf)
    (pop-to-buffer buffers-except recentf))
  "Alist with entries of the form (CMD . COMPLETES).
COMPLETES is a list defining what's completed where entries can

- `buffers':        completion for all buffers
- `buffers-except': completion for all buffers except the current one
- `recentf':        completion for recent files which will be found on demand")

(defun th/read-buffer-or-recentf (prompt &optional
                                         def require-match predicate)
  (let* ((tables (or
                   (lambda (syms)
                     (pcase syms
                       ('buffers #'internal-complete-buffer)
                       ('buffers-except (internal-complete-buffer-except
                       ('recentf (completion-table-dynamic
                                  (lambda (s) recentf-list)))
                       (unknown  (error "Unknown case %S" unknown))))
                   (cdr (assoc this-command
                  (list #'internal-complete-buffer
                         (lambda (s) recentf-list)))))
         (completion-table (apply #'completion-table-in-turn tables)))
    ;; `read-buffer-to-switch' (called by `switch-to-buffer') already sets
    ;; `internal-complete-buffer' as `minibuffer-completion-table' using
    ;; `minibuffer-with-setup-hook' before `read-buffer-function' is invoked by
    ;; `read-buffer', so we'd be restricted to buffers by default.  Therefore,
    ;; append a function setting our completion table.
        (:append (lambda ()
                   (setq-local minibuffer-completion-table completion-table)))
      (when-let ((result (completing-read prompt completion-table
                                          predicate require-match nil
                                          'buffer-name-history def)))
         ((get-buffer result) result)
         ((file-exists-p result) (buffer-name (find-file-noselect result)))
         (t result))))))

(setq read-buffer-function #'th/read-buffer-or-recentf)

This custom read-buffer-function just arranges that a suitable completion table is used, then does the completion using completing-read and if the result is no buffer but an existing file, it finds it and returns the corresponding new buffer.

The defconst th/read-buffer-or-recentf-command-alist allows for defining what's suggested by completion on a command by command basis. For example, completion for recent files makes totally no sense for kill-buffer while completing the current buffer is not too sensible for switch-to-buffer and pop-to-buffer. If there's no entry in the alist, the default behavior is to offer completion for all buffers and then for recent files.

It's important to note that in contrast to virtual buffers in ido.el or consult, this read-buffer-function is in effect whenever emacs queries for a buffer, not just in certain buffer switching commands. As an example, when doing M-x append-to-buffer RET now I can also complete a recent file instead of an existing buffer which will be opened on demand. That's quite cool. A caveat is, however, that the position where the current region will be appended depends on where point has been when I visited that file the last time because I use save-place-mode. If I wouldn't use it, the region would be prepended to the beginning of the file/buffer which would also be awkward. But I guess the actual inconsistency is due to the fact that append-to-buffer inserts at the location of point which doesn't quite fit to the "append" in its name.

Anyway, after a few days of using it, I must say, it works extremely well for me. One thing which doesn't work completely is marginalia annotations. During completion, buffers are annotated as usual, but when recent file completions kick in, those are not. I've tried making them respond to metadata requests which didn't work out because right now, emacs' completion framework doesn't really support combining completion tables which have different metadata, e.g., one says the category is buffer, the other says it's file. Well, that's probably a sensible assumption in all cases but this one. :-)