next_posts_link URL does not include name of custom post type

That is actually the expected output of next_posts_link(), i.e. it always uses the current page URL. And the same also applies to previous_posts_link().

So if you’re on the homepage (localhost/), then the URL of page 2 would be localhost/page/2, and if you’re on (your custom post type archive page at) localhost/todo, then the URL of page 2 would be localhost/todo/page/2.

And if you want to alter the main WordPress query on the homepage, then you should use the pre_get_posts hook instead of making a secondary/custom WP_Query call (new WP_Query()) in your index.php template.

So for example, to display 3 todo posts on the homepage,

  1. Add this to your theme’s functions.php file:

    add_action( 'pre_get_posts', 'my_pre_get_posts' );
    function my_pre_get_posts( $query ) {
        // If we're on the homepage, query 3 "todo" posts instead of the default ones.
        if ( is_home() ) {
            $query->set( 'post_type', 'todo' ); // query posts of the "todo" type only
            $query->set( 'posts_per_page', 3 ); // and display three posts per page
        }
    }
    
  2. Then in your index.php template, just remove those $todos and $args, and use the default loop to display the posts returned via the main query:

    <?php
    // No need for the $todos and $args.
    ?>
    
        <main id="primary" class="site-main">
            <section class="glass">
                <?php if ( have_posts() ) : ?>
                    <div class="todolist">
                        <?php while ( have_posts() ) : the_post(); ?>
                            <div class="todo">
                                ... your code.
                            </div>
                        <?php endwhile; ?>
                    </div>
    
                    ... your pagination here.
                <?php endif; // end have_posts() ?>
            </section>
    

That way, even if next_posts_link() and previous_posts_link() do not add the todo/ to the pagination links, you wouldn’t get the error 404 when going to localhost/page/2 unless of course if there’s no page 2 for the main query.

And if you wonder what that “main” (WordPress) query is, then check the Codex, specifically the part which says, “Save the posts in the $wp_query object to be used in the WordPress Loop.“.

Also, your pagination code will show an empty bullet when there’s no next/previous posts link, so to fix that, you’d want to use get_previous_posts_link() instead of previous_posts_link() and get_next_posts_link() instead of next_posts_link(). Example:

<?php // this would replace the "... your pagination here." part above.

$links="";

if ( $link = get_previous_posts_link( 'Previous Posts' ) ) {
    $links .= "<li>$link</li>";
}

if ( $link = get_next_posts_link( 'Next Posts' ) ) {
    $links .= "<li>$link</li>";
}

if ( $links ) {
    echo "<ul>$links</ul>";
}
?>

Additionally, in your $args array, you should’ve used paged (note the “d”) and not page, i.e. 'paged' => $paged. But I’m just saying that so you know the correct arg is paged. 🙂