I’ll skip over the output buffer solution since it complicates things in some ways even though it’s a good solution. The usual technique involves collecting all output into a single variable.
function shortcode_handler( $atts ){
global $post;
$myposts = get_posts();
$output = "";
foreach( $myposts as $key => $post ){
setup_postdata($post);
$output .= '<h1>' . get_the_title() . '</h1>';
$output .= get_the_excerpt();
$output .= '<a href="' . get_the_permalink() . '">More</a>';
}
wp_reset_postdata();
return $output;
}
This function will not do exactly what you want because I simplified some things for the sake of an example. Just add your specific details back in. Note that the post functions are slightly different, they are variants that return content instead of echoing.
You actually need to add shortcodes to content to get them to work. PHP does not recognize shortcodes as themselves. If you must execute a shortcode within PHP, use the do_shortcode() function.
Pagination is generally handled as part of the query, not within a WP Loop. The query limits any results to the posts_per_page amount. The Loop outputs everything returned. If a different page is requested, the offset into matching results is calculated. If you want page 3 and there are 10 posts per page, the offset will be 20. (page 1 is posts 0-9, page 2 is 10-19, etc.)
While WP_Query has an offset argument, using it will break the default pagination, which in some cases is what you need in order to have pagination work. If the default pagination functions don’t work, that’s a sign that you need to take over the process and manage it yourself.
See Making Custom Queries using Offset and Pagination.