Pagination of a WP_Query Loop in a child-page page template

Glad that you managed to figure out a solution, which yes, does work. But you could make it more selectively, i.e. target just the specific pages, by using is_single( '<parent slug>/<child slug>' ), e.g. is_single( 'family-law/success-stories' ) in your case.

Secondly, it’s not exactly the redirect_canonical hook which caused the (301) redirect on singular paginated post pages (with URL having the /page/<number>). Instead, it’s the $addl_path part in the redirect_canonical() function which fires the redirect_canonical hook. More specifically, that part runs only if ! is_single() is true (and that the current page number is 2 or more), and thus:

  • If the request (i.e. current page) URL was for a CPT at example.com/practice-areas/family-law/success-stories/page/2/ where practice-areas is the CPT slug and success-stories is child of family-law, the redirect/canonical URL would not contain page/2/ because ! is_single() is false, hence $addl_path would be empty.

    The same scenario would also happen on example.com/practice-areas/family-law/page/2/ (i.e. page 2 of the parent post) and other singular CPT pages, including regular posts (in the default post type), e.g. at example.com/hello-world/page/2/, but not on singular Pages (post type page) because ! is_single() is false — because is_single() works with any post type, except attachment and page.

  • So if the request URL does not match the canonical URL, then WordPress runs the (301) redirect, which explains why this happened: “everytime I try to navigate to /page/child-page/page/2/ WordPress sends me back to /page/child-page/ with a 301 redirect“.

Also, redirect_canonical() is hooked on template_redirect, so instead of having the function runs the various logic of determining the redirect/canonical URL and yet you eventually return an empty string, you might better off just disable the function like so:

add_action( 'template_redirect', 'my_template_redirect', 1 );
function my_template_redirect() {
    if ( is_paged() && is_single( 'family-law/success-stories' ) ) {
        remove_action( 'template_redirect', 'redirect_canonical' );
    }
}

And yes, no extra rewrite rules are needed because WordPress already generated them (which handle the /page/<number>) for your CPT. But I don’t know for sure why WordPress does the above ! is_single() check. 🤔

Last but not least, in my original answer and comment as well, I suggested you to remove the $temp_query parts, and your reply was: “I’m gonna keep the $temp_query bit though, since it allows our pagination component to work without the need to rewrite or complicate things” and “I think you were just saying it’s not needed“.

So yes, you’re right with that second sentence. But more precisely, I actually tested your template code where I put it in single-practice-areas.php and then removed the $temp_query parts (and used $total_pages = $practice_area_query->max_num_pages;), and the /page/<number> pagination worked just fine for me with the help of the above my_template_redirect() function.

So I wondered what exactly does the $temp_query fix, or why must you assign the $wp_query to $practice_area_query?

But if you really must keep it, then you need to move the wp_reset_postdata(); to after the $wp_query = $temp_query;, i.e. after you restore the global $wp_query variable back to the actual main query. Because otherwise, if for example you run the_title(), you would see the title of the last post in your custom query and not the current (and the only) one in the main query.