I have custom query with custom pagination with which I'd like to use pretty urls, like the standard pagination, rather than appending a query string to the url, ie


instead of


Creating pretty urls was easy enough using the format parameter of paginate_links()

$args = array(
    'format'    => 'cpage/%#%',
    'current'   => $cpage,
    'total'     => $custom_query->max_num_pages,
    'type'      => 'array',
    'prev_text' => __('Previous'),
    'next_text' => __('Next'),
$paging = paginate_links( $args );

The problem I'm having is with needing to then rewrite those urls back to a query string when the page is visited, because WP doesn't recognise my 'cpage' variable within a pretty url.

So I'm rewriting the urls in .htaccess like this

RewriteRule ^/posttype/([^/]*)/cpage/([0-9]*) /posttype/$1/?cpage=$2 [QSA]

and I've verified this rule should rewrite the way I expect using http://martinmelin.se/rewrite-rule-tester/. When applying the rule to the .htaccess on my site however I get a 404 when visiting pagination links and I don't understand why that is. Paging works just fine if I don't use pretty urls.

I've used mod_rewrite many times in the past, but never with WP. Are there any gotchas I should be aware of that might affect what I'm trying to do, or is there a better way to achieve this?

Post type registration using Toolset Types plugin: Post type registration using Toolset Types plugin


WP_Post_Type Object
    [name] => deal
    [label] => Deals
    [labels] => stdClass Object
            [name] => Deals
            [singular_name] => Deal
            [add_new] => Add New
            [add_new_item] => Add New Deal
            [edit_item] => Edit Deal
            [new_item] => New Deal
            [view_item] => View Deal
            [search_items] => Search Deals
            [not_found] => No Deals found
            [not_found_in_trash] => No Deals found in Trash
            [parent_item_colon] => Parent text
            [all_items] => All items
            [archives] => All items
            [insert_into_item] => Insert into post
            [uploaded_to_this_item] => Uploaded to this post
            [featured_image] => Featured Image
            [set_featured_image] => Set featured image
            [remove_featured_image] => Remove featured image
            [use_featured_image] => Use as featured image
            [filter_items_list] => Filter posts list
            [items_list_navigation] => Posts list navigation
            [items_list] => Posts list
            [menu_name] => Deals
            [enter_title_here] => Enter title here
            [name_admin_bar] => Deal

    [description] => The link between compare and operator
    [hierarchical] => 
    [exclude_from_search] => 1
    [publicly_queryable] => 1
    [show_ui] => 1
    [show_in_menu] => 1
    [show_in_nav_menus] => 1
    [show_in_admin_bar] => 1
    [menu_position] => 
    [menu_icon] => dashicons-admin-post
    [capability_type] => post
    [map_meta_cap] => 1
    [register_meta_box_cb] => 
    [taxonomies] => Array

    [has_archive] => 
    [query_var] => deal
    [can_export] => 1
    [delete_with_user] => 
    [_builtin] => 
    [_edit_link] => post.php?post=%d
    [cap] => stdClass Object
            [edit_post] => edit_post
            [read_post] => read_post
            [delete_post] => delete_post
            [edit_posts] => edit_posts
            [edit_others_posts] => edit_others_posts
            [publish_posts] => publish_posts
            [read_private_posts] => read_private_posts
            [read] => read
            [delete_posts] => delete_posts
            [delete_private_posts] => delete_private_posts
            [delete_published_posts] => delete_published_posts
            [delete_others_posts] => delete_others_posts
            [edit_private_posts] => edit_private_posts
            [edit_published_posts] => edit_published_posts
            [create_posts] => edit_posts

    [rewrite] => Array
            [enabled] => 1
            [with_front] => 1
            [pages] => 1
            [feeds] => 
            [slug] => deal
            [ep_mask] => 1

    [_toolset_edit_last] => 1482798189
    [_wpcf_author_id] => 1
    [wpcf-post-type] => deal
    [icon] => admin-post
    [slug] => deal
    [dashboard_glance] => 1
    [custom-field-group] => Array
            [5] => 1

    [has_archive_slug] => 
    [show_in_menu_page] => 
    [query_var_enabled] => 1
    [permalink_epmask] => 1
    [rest_base] => deal
    [post_relationship] => Array
            [belongs] => Array
                    [compare] => 1
                    [operator] => 1


    [show_in_rest] => 

rewrite analyzer plugin output enter image description here

  • what is _compare? shouldn't the URLs be deal/post-name/?
    – Milo
    Commented Dec 29, 2016 at 5:36
  • _compareis the slug for categories, with allbeing a category containing all compare posts (yes I know its horrible but that's how I inherited it). What we are actually paging however is a sub query of deal posts, which is a child of compare.
    – gpcola
    Commented Dec 29, 2016 at 5:52
  • rewrite endpoint needs to be added for EP_CATEGORIES then if that is a category archive, not EP_PERMALINK.
    – Milo
    Commented Dec 29, 2016 at 6:17

1 Answer 1


You can handle this with WordPress's internal rewrites system by adding a rewrite endpoint:

function wpd_cpage_endpoint() {
    add_rewrite_endpoint( 'cpage', EP_PERMALINK );
add_action( 'init', 'wpd_cpage_endpoint' );

This will add extra rewrite rules enabling /cpage/n/ on the end of permalinks.

You can then fetch the value on those requests with get_query_var( 'cpage' ).


This is the code I used to register the post type and endpoint:

function wpd_test_post_type() {

    $args = array(
        'label' => 'Test Type',
        'public' => true,
        'rewrite' => true,
        'supports' => array( 'title' ),


    add_rewrite_endpoint( 'cpage', EP_PERMALINK );

add_action( 'init','wpd_test_post_type' );
  • Thanks for your input Milo. I added the wpd_cpage_endpoint function to my functions.php and updated the 'current' parameter of paginate_links and the 'paged' parameter of my custom query with get_query_var('cpage'), but I'm still getting 404s on any page except the first unfortunately. Do I also need to register that query var using add_query_vars_filter? I tried that too and also tried saving the permalink settings page in admin, thinking maybe the rules need to be updated, but still got 404s :(
    – gpcola
    Commented Dec 26, 2016 at 22:38
  • Adding the endpoint also adds the query var. Rewrites do need to be flushed after adding the endpoint, which can be accomplished just by visiting the permalinks settings page. Make sure you've removed whatever lines were added to .htaccess, and maybe add your post type registration code to your question as well.
    – Milo
    Commented Dec 26, 2016 at 23:14
  • I had removed the .htaccess rewrite rules prior to trying out your suggestion Milo. I've now added the custom post type configuration to my question. I'm using the Toolset Types plugin to handle custom post types as the the relationships are sufficiently complex and it's not something I've had a lot of experience with. Hopefully the screen shot gives enough information to be useful though?
    – gpcola
    Commented Dec 26, 2016 at 23:43
  • I edited the answer to add a full working example, tested and working in a default install with the 2016 theme.
    – Milo
    Commented Dec 26, 2016 at 23:54
  • Milo many thanks for your assistance. Unfortunately, although I can see that your code woks (and it makes sense that it would), I haven't been able to make it work in my particular case. Regardless, I'll wait another day to see f anyone else has more to add, but I'm happy to accept your solution as the answer. Thanks again.
    – gpcola
    Commented Dec 27, 2016 at 4:41

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.