Pagination Not Working on `WP_Query` Archive Page

Note that with a custom WP_Query class instance like the $homePageArticles in your case, you should pass the max_num_pages property of the class instance to paginate_links():

paginate_links( array(
    'total' => $homePageArticles->max_num_pages,
) )

However, that alone will not work with custom offset which breaks the pagination, hence for example you got this issue:

it just shows the same posts on each page

But it can be fixed and it’s quite easy:

  1. Calculate the offset based on the current page number and pass the offset to WP_Query:

    // Current page number.
    $paged = max( 1, get_query_var( 'paged' ) );
    
    $per_page     = 18; // posts per page
    $offset_start = 9;  // initial offset
    $offset       = $paged ? ( $paged - 1 ) * $per_page + $offset_start : $offset_start;
    
    $homePageArticles = new WP_Query( array(
        'posts_per_page' => $per_page,
        'offset'         => $offset,
        'post_type'      => 'articles',
        // No need to set 'paged'.
    ) );
    
  2. Recalculate the max_num_pages property and pass it to paginate_links():

    $homePageArticles->found_posts   = max( 0, $homePageArticles->found_posts - $offset_start )
    $homePageArticles->max_num_pages = ceil( $homePageArticles->found_posts / $per_page );
    
    while ( $homePageArticles->have_posts() ) ...
    
    echo paginate_links( array(
        'current' => $paged,
        'total'   => $homePageArticles->max_num_pages,
        ...
    ) );
    

But then, if you’re making the custom WP query in an archive template, e.g. archive-articles.php (archive-<post type>.php)

Then you should just forget that custom WP query.

And instead, use the pre_get_posts hook to filter the main WP query args (to set the custom offset), then use the found_posts hook to make sure we have the correct max_num_pages value, then just loop through the posts in the main query.

  1. In the theme functions file:

    function my_pre_get_posts( $query ) {
        if ( ! is_admin() && $query->is_main_query() &&
            is_post_type_archive( 'articles' )
        ) {
            $query->set( 'offset_start', 9 );
            $query->set( 'posts_per_page', 18 );
        }
    
        if ( $offset = $query->get( 'offset_start' ) ) {
            $per_page = absint( $query->get( 'posts_per_page' ) );
            $per_page = $per_page ? $per_page : max( 1, get_option( 'posts_per_page' ) );
    
            $paged = max( 1, get_query_var( 'paged' ) );
            $query->set( 'offset', ( $paged - 1 ) * $per_page + $offset );
        }
    }
    add_action( 'pre_get_posts', 'my_pre_get_posts' );
    
    function my_found_posts( $found_posts, $query ) {
        if ( $offset = $query->get( 'offset_start' ) ) {
            $found_posts = max( 0, $found_posts - $offset );
        }
    
        return $found_posts;
    }
    add_filter( 'found_posts', 'my_found_posts', 10, 2 );
    
  2. Then in your archive template:

    // No need for the "new WP_Query()".
    while ( have_posts() ) : the_post();
        ... your code.
    endwhile;
    
    // No need to set 'current' or 'total'.
    echo paginate_links( array(
        'prev_text' => 'NEWER',
        'next_text' => 'OLDER',
        ...
    ) );
    

And actually, with the custom functions in #1 above, you could simply use the custom offset_start arg with any WP_Query instances:

// Current page number.
$paged = max( 1, get_query_var( 'paged' ) );

$homePageArticles = new WP_Query( array(
    'posts_per_page' => 18,
    'offset_start'   => 9, // <- set this
    'post_type'      => 'articles',
    // No need to set 'paged' or 'offset'.
) );

while ( $homePageArticles->have_posts() ) ...

echo paginate_links( array(
    'current' => $paged,
    'total'   => $homePageArticles->max_num_pages,
    ...
) );