To show a returning visitor the post they most recently read, save the current post ID in a site-specific cookie, then query that ID and render its title and link later. The example below keeps a short, ordered history, works for anonymous visitors, and exposes the display through a [last_visited_posts] shortcode.
What this implementation does
- Records published single-post views in a cookie named
freedom251_post_history. - Moves a revisited post to the newest position and keeps at most five IDs.
- Reads, validates and deduplicates the IDs before querying WordPress.
- Displays only currently published posts, so private or unavailable content is not exposed.
- Lets an editor place the result with a shortcode.
This is a visitor-specific browsing-history cookie. It is separate from the cookies WordPress uses for login sessions or commenter convenience, as described in the WordPress Cookies documentation.
Add the history code
Put the following in a small site-specific plugin or in a code-snippet tool such as WPCode. A site-specific plugin is usually easier to keep when changing themes.
<?php
/**
* Save the current published post in a visitor's short history.
*/
function freedom251_record_post_history() {
if ( ! is_singular( 'post' ) || ! is_user_logged_in() && headers_sent() ) {
return;
}
$post_id = get_queried_object_id();
if ( ! $post_id || 'publish' !== get_post_status( $post_id ) ) {
return;
}
$cookie_name = 'freedom251_post_history';
$limit = 5;
$old_value = isset( $_COOKIE[ $cookie_name ] ) ? wp_unslash( $_COOKIE[ $cookie_name ] ) : '';
$old_ids = array_filter( array_map( 'absint', explode( ',', $old_value ) ) );
// Remove this post wherever it already appears, then add it as newest.
$old_ids = array_values( array_diff( $old_ids, array( $post_id ) ) );
array_unshift( $old_ids, $post_id );
$old_ids = array_slice( $old_ids, 0, $limit );
setcookie(
$cookie_name,
implode( ',', $old_ids ),
array(
'expires' => time() + ( 30 * DAY_IN_SECONDS ),
'path' => COOKIEPATH ? COOKIEPATH : '/',
'secure' => is_ssl(),
'httponly' => true,
'samesite' => 'Lax',
)
);
// Make the new value available during this request as well.
$_COOKIE[ $cookie_name ] = implode( ',', $old_ids );
}
add_action( 'template_redirect', 'freedom251_record_post_history' );
/**
* Render the saved posts with [last_visited_posts].
*/
function freedom251_last_visited_posts_shortcode() {
$cookie_name = 'freedom251_post_history';
$raw_value = isset( $_COOKIE[ $cookie_name ] ) ? wp_unslash( $_COOKIE[ $cookie_name ] ) : '';
$ids = array_filter( array_map( 'absint', explode( ',', $raw_value ) ) );
$ids = array_values( array_unique( $ids ) );
if ( empty( $ids ) ) {
return '';
}
$posts = get_posts(
array(
'post_type' => 'post',
'post_status' => 'publish',
'post__in' => $ids,
'orderby' => 'post__in',
'posts_per_page' => count( $ids ),
'ignore_sticky_posts' => true,
'no_found_rows' => true,
)
);
if ( empty( $posts ) ) {
return '';
}
$output = '<ul class="last-visited-posts">';
foreach ( $posts as $post ) {
$output .= sprintf(
'<li><a href="%1$s">%2$s</a></li>',
esc_url( get_permalink( $post ) ),
esc_html( get_the_title( $post ) )
);
}
$output .= '</ul>';
return $output;
}
add_shortcode( 'last_visited_posts', 'freedom251_last_visited_posts_shortcode' );
The template_redirect hook runs early enough for the response cookie to be sent before page output. The condition intentionally records only single posts with the public publish status. If your site uses a custom post type, change is_singular( 'post' ), post_type, and the status check together.
#1 Best Overall
Place the list on a page
- Create or edit the page, widget, block, or template area where the list should appear.
- Insert
[last_visited_posts]in a Shortcode block or the editor location that supports shortcodes. - Visit several published posts in a private browser window, then return to that page. The newest post should appear first.
WordPress describes the Shortcode API as a set of functions for creating shortcodes used in posts and pages. A shortcode is therefore appropriate when an editor controls placement. For a fixed sidebar or header, call the same rendering function from a theme template or wrap it in a custom block instead.
How the cookie and query are kept safe
Validate every stored value
Cookies are visitor input. The code converts each entry to an integer, removes empty values and duplicates, and returns nothing for a missing or unusable cookie. Do not use a cookie value directly in SQL or print it as HTML.
Rank #2
Preserve the visitor’s order
post__in limits the query to saved IDs, while orderby => post__in keeps the newest-to-oldest order established by the cookie. A normal date sort could show a different order from the visitor’s actual reading history.
Keep restricted content out
The query requests only post_status => publish. That prevents this public widget from intentionally listing drafts, private posts, or other non-public statuses. If you add a custom visibility rule, apply it before output as well.
Quick wins for a faster PC:
Repair Windows errors before they cause bigger problemsFix Now →Scan for outdated or missing drivers - takes under a minuteDriver Scan →Clear out junk files and repair common Windows errorsFree Scan →Rank #3
Shortcode, template, block or REST API?
| Approach | Best use | Visitor state | Important consideration |
|---|---|---|---|
| Shortcode | An editor chooses the page or content location | Cookie-based history for anonymous visitors | Easy placement; output still varies by visitor and must be handled by caching correctly |
| Theme template | A fixed area such as a sidebar or account layout | Cookie-based history, or another server-side design | Requires theme or plugin ownership and PHP changes |
| Custom block | A block-editor component with controlled markup and styling | Usually the same cookie logic | More development work, but better editor controls |
| REST-powered component | A JavaScript interface that fetches public post details | Client-held IDs or a cookie sent to your own endpoint | Expose only public data; WordPress cookie authentication and nonces are for authenticated requests, not anonymous identification |
| Account history | A signed-in user should see history across devices | Server-side records tied to an account | This is a different design from the anonymous cookie pattern and needs its own storage, retention and privacy decisions |
The WordPress Posts REST API documents public and restricted post data boundaries. If you build a REST variation, do not return private content merely because an ID was supplied by a browser.
Caching, consent and privacy checks
Page caching
A full-page cache can serve one visitor’s rendered list to another, or serve a page generated before the cookie existed. Configure the cache to vary this component by cookie, bypass caching for the personalized response, or render the list client-side. The cited implementation example does not establish compatibility with particular themes or caching plugins, so verify it on your stack.
Rank #4
Cookie policy
Decide how the 30-day feature cookie fits your privacy notice, consent banner and retention policy. Requirements differ by jurisdiction and site configuration; the implementation guidance does not establish a universal legal exemption. If consent is required for this cookie on your site, set it only after the relevant consent signal and provide a way to clear it.
Logged-in and anonymous testing
Test in separate browser profiles. A logged-in WordPress session cookie does not automatically provide anonymous visitor history, and the history cookie in this example does not authenticate a user.
Troubleshooting
- No list appears: confirm the shortcode is exactly
[last_visited_posts], browse at least one published post, and inspect whether the browser accepted the cookie. - The cookie never appears: check for output sent before
template_redirect, confirm the site uses HTTPS when expected, and verify the cookie path and domain settings. - Posts appear in the wrong order: keep
orderby => post__in; removing it allows the database’s normal ordering to take over. - A cached list is shared between visitors: exclude the personalized fragment from full-page caching or move rendering to a request made after page load.
- A deleted or private post is missing: that is expected; the query deliberately limits results to currently published posts.
- Your site uses a custom post type: update the singular check, query’s
post_type, and status rules consistently, then test permissions and public visibility.
Before putting it live
- Confirm the cookie name does not collide with another plugin.
- Set the history limit and expiration to match your privacy notice.
- Test first visit, repeat visit, revisiting an older post, a malformed cookie, and an empty history.
- Test logged-in and logged-out browsers, mobile and desktop layouts, and every page-cache layer.
- Check that the output contains no drafts, private posts, or posts the visitor cannot publicly view.
- Document the cookie in your site’s privacy and cookie controls where required.
The Bottom Line
Record a small, validated list of post IDs in a feature-specific cookie, retrieve only public posts in that saved order, and render the result with a shortcode or fixed layout component. Treat caching and cookie-consent behavior as part of the implementation, not as an afterthought.
Quick Recap
Best Value
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




