If you want to load the post faster you can use JS to fetch your post, but on modern browsers probably preloading pages will be faster, the only drawback of preloading is that you’ll consume storage for your visitors even if they don’t visit the preloaded links, also with preloading links you will consume more bandwidth and resources on the server-side.

So if you want to stick with fetching with JS here is an example, but first, as a note, the example will not use the default REST API mostly because the default API only returns the content title and other attributes of the post and, we want to add things like Yoast data(without making two API request) also, in this case, will transfer some of the article template surrounding the content.

So first we start with adding a REST Endpoint:

add_action('rest_api_init', 'change_rest_post' );
function change_rest_post(){
   
  register_rest_route( 'a309/v1', '/get-post/(?P<id>\d+)/user/(?P<user_id>\d+)', array(
    'methods' => 'GET',
    'callback' => 'get_post_by_id',
    'permission_callback' => '__return_true',
  ) );
}

Simple API takes two parameters, obvious the post-id, and the user-id. If you wonder why the user-id is passed is, for the comments section.

Next the callback function get_post_by_id:

//...

function a309_get_post_template($wpQueryArgs = null ,$full = true, $yoastSeo = false){
    
   $my_posts = new WP_Query($wpQueryArgs);  
   global $post;
   $yoast_head = null;
   $template = '';

   if($my_posts->have_posts()) : 
       while ( $my_posts->have_posts() ) : $my_posts->the_post(); 
          if($yoastSeo){
          ob_start();
          do_action("wpseo_head");
          $yoast_head = ob_get_clean();
          }
          ob_start();
          get_template_part( 'parts/article', null, [ 'full_content' => $full ]);
          $template .= ob_get_clean();
          endwhile; //end the while loop
endif; // end of the loop.
 
if($yoastSeo){
    return ['template' => $template, 'yoast_head' => $yoast_head ];
}else{
    return $template;
 }   
}

function get_post_by_id($data){
 global $withcomments;
 global $wp_query;
 $wp_query->is_singular = true;
 $withcomments = true;
 
$data['user_id'] = intval($data['user_id']);
$user_id = $data['user_id'] > 0 ? $data['user_id']: 0;
 
global $current_user;
$current_user = new stdClass();
$current_user->ID =  $user_id;

 $args = array(
        'post_type' => 'post',
        'post_status' => 'publish',
        'p' => $data['id'],   // id of the post you want to query
    );
    
  $post_html = a309_get_post_template($args, true, true);

  wp_send_json([ 'article' => $post_html['template'], 'post_id' =>  $data['id'], 'yoast_seo' => $post_html['yoast_head']  ]);
 
}

//...

There is some integration with yoast_seo plugin, also the a309_get_post_template is used also when getting more than one post then we need to get a list with excerpts. We return JSON and the template key is the node HTML of the entire article, which was outputted using the WP function get_template_part that in turn will get the file article.php, and for reference that file is:

<?php 

$articleFull = (isset($args['full_content']) && $args['full_content']);
        
?>
<article itemscope itemtype="http://schema.org/BlogPosting" class="<?php echo $articleFull? '':'content-va-on ' ?>post-body mb-2 <?php echo implode(' ',get_post_class()) ?>" id="post-<?php the_ID(); ?>" data-id="<?php the_ID(); ?>" data-slug="<?php echo $post->post_name ;?>" data-title="<?php echo $post->post_title ;?>">
     <header>
         <?php  if($articleFull): ?>
         <h1 itemprop="headline" class="blog-post-title">
         <?php          
         else: ?>
         <h2 itemprop="headline" class="blog-post-title blog-post-title-link">
         <a href="<?php echo esc_url( get_the_permalink() ); ?>" title="<?php echo esc_attr( get_the_title() ); ?>">
         <?php endif; ?>
        
         <?php the_title(); ?>
        
         <?php  if($articleFull): ?>
            </h1>
         <?php
         else: ?>
          </a>
         </h2>
         <?php endif; ?>
         
        <div class="blog-post-meta flex flex-row">
            <address class="author px-2 pt-3 pb-6"><a rel="author" title="Author's page" href="<?php echo site_url(); ?>/author/andrei0x309/"><i class="icon-user-solid-square"></i> <?php the_author(); ?></a></address> 
            <time class="px-2 pt-3 pb-6" itemprop="published" datetime="<?php echo get_the_date('Y-m-d'); ?>" title="<?php echo get_the_date(); ?>"><i class="icon-calendar"></i> <?php echo get_the_date(); ?></time>
            <span class="px-2 pt-3 pb-6" ><i class="icon-folder"></i> <?php the_category( ', ' ); ?></span> 
        </div>     
     </header><!--  !-->
     <div itemprop="articleBody" class="article-body">
     <?php
          if ( function_exists('yoast_breadcrumb') && $articleFull ):
     yoast_breadcrumb( '<div id="breadcrumbs" class="mb-2">','</div>' );
     endif;
     ?>
 <?php if(has_post_thumbnail()) { ?>
           <?php if (!$articleFull) { ?> <a href="<?php echo get_the_permalink(); ?>" 
            title="<?php echo esc_attr( get_the_title() ); ?>" id="featured-thumbnail-<?php the_ID(); ?>" 
           class="post-image post-image-left p-0 <?php echo $articleFull ? '': 'post-image-link'; ?>"><?php } ?>
                <?php echo '<div class="pt-2 pr-4 pl-4 pb-6 featured-thumbnail w-full content-center justify-center md:w-2/5 md:float-left text-center">'; 
                $alt = a309_thumbnail_get_alt();
               ?>
               <img class="m-auto wp-post-image" width="500" height="281" loading="lazy" src="<?php echo a309_resize_img_src(get_the_post_thumbnail_url(),500) ?>" <?php echo ($alt) ? 'alt="'.$alt.'"': 'alt';  ?> >
                <?php
                echo '</div>'; ?>
            <?php if (!$articleFull) { ?> </a> <?php } ?>
<?php } ?>   

 <?php  if($articleFull){ 
      the_content(); 
 if ( function_exists( 'ADDTOANY_SHARE_SAVE_KIT' ) ) { 

     if(a309_is_amp()): ?>
 <amp-script layout="container"  src="<?php echo get_stylesheet_directory_uri() ?>/js/AMP/amp_addtoany.js">
         <?php
ADDTOANY_SHARE_SAVE_KIT( array( 
        'buttons' => array( 
            'twitter',
            'reddit',
            'pinterest',
            'whatsapp',
            'facebook',
            'email',
            'print' )) );
     else:
          ADDTOANY_SHARE_SAVE_KIT( array( 
        'buttons' => array( 
            'twitter',
            'reddit',
            'pinterest',
            'whatsapp',
            'facebook',
            'email',
            'print' ),
    ) );
     endif;
      if(a309_is_amp()): ?>
       </amp-script>  
      <?php  endif;
}    
 }else{
   the_excerpt();  
 }
 ?>
 </div>
 </article><!-- /.blog-post -->
 <?php
 if($articleFull){
 echo do_shortcode('[yarpp]');
 
 comments_template();
  }

Most of the unfamiliar functions are in general from plugins, or from template.

So now to the JS part, we should load this JS only on our index page:

 
 /* global SyntaxHighlighter */

// Document Ready
document.addEventListener("DOMContentLoaded", function () {

// Get Some Elements we need
    window.articleNodes = null;
    window.mainTagEL = document.querySelector('main');
 
    // Add Open Post Event
    modifyEventForOpenPost(['.read-more', '.post-image-link', '.blog-post-title-link' ]);

});

let isPostOpen = false;
let lastPostOpen = null;
let homeSeoHead = '';

window.onpopstate = () => {
    if(isPostOpen) backToPosts();
    else if(lastPostOpen) openPost(lastPostOpen);
};


const modifyEventForOpenPost = ( selectors = [], remove=false) => {
    for(const selector of selectors){
    document.querySelectorAll(selector).forEach(
            (node) => {
        if(!remove) node.addEventListener('click', openPost);
        else node.removeEventListener('click', openPost);
    });
    }
};

const addSpinner = (articleStart) => {

    const spinnerTag = document.createElement('div');
    spinnerTag.id = 'loadingSpinner';
    spinnerTag.style = 'position: absolute; width:100%; height:100%;  left: 0; top: 0;  background: rgb(183 183 183 / 72%);';

    spinnerTag.innerHTML = `
    <div class='sk-folding-cube' style="position: absolute; left:45%; top:${articleStart + 80}px"%>
    <div class='sk-cube sk-cube-1'></div>
    <div class='sk-cube sk-cube-2'></div>
    <div class='sk-cube sk-cube-4'></div>
    <div class='sk-cube sk-cube-3'></div>
    <div class='sk-cube-txt'>Loading</div>
    </div>`;

    window.mainTagEL.appendChild(spinnerTag);
    return spinnerTag;
};

const addPost = (postData) => {
    let postEl = null;

    if (postData.article) {
        window.mainTagEL.insertAdjacentHTML('afterbegin', postData.article);
        postEl = document.getElementById(`post-${postData.post_id}`);
    }
    return postEl;
};

const removeSpinner = (spinnerTag) => {
    window.mainTagEL.removeChild(spinnerTag);
};


const getSeoHead = () => {
    homeSeoHead = '';
    const head = document.getElementsByTagName('head')[0];
    const nodes = [...head.childNodes].filter(el => el instanceof HTMLElement);
      console.log(nodes);
    for(let node of nodes){
            const nodeName = node.nodeName.toLowerCase();
            if(nodeName === 'link' && node.rel !== 'canonical') continue;
            if(nodeName === 'script' && node.type !== 'application/ld+json' ) continue;
            if(nodeName === 'meta') 
            if(node.getAttribute('charset') !== null || node.getAttribute('http-equiv') !== null)continue;    
            homeSeoHead += node.outerHTML;
    }
};

const updateHead = (yoastHeadData) => {

    const head = document.getElementsByTagName('head')[0];
    const next = head.querySelector(`link[rel="next"]`);
    if (next)
        next.parentElement.removeChild(next);
    let template = document.createElement('template');
    template.innerHTML = yoastHeadData;
    let allNodes = [...template.content.childNodes];
    if (allNodes) {
        allNodes = allNodes.filter(el => el instanceof HTMLElement);
    }

    const replaceContentOrAddMetaEl = (attribute, node) => {
        const existingElement = head.querySelector(`meta[${attribute}="${node.getAttribute(`${attribute}`)}"]`);
        if (existingElement) {
            existingElement.content = node.content;
        } else {
            head.appendChild(node);
        }
    };

    allNodes.forEach(
            (node) => {

        switch (node.nodeName.toLowerCase()) {
            case 'meta':
                if (node.hasAttribute('name')) {
                    replaceContentOrAddMetaEl('name', node);
                } else if (node.hasAttribute('property')) {
                    replaceContentOrAddMetaEl('property', node);
                }
                break;
            case 'title':
                document.title = node.textContent;
                break;
            case 'script':
                const existingScript = head.querySelector('script[type="application/ld+json"]');
                if (existingScript)
                    head.removeChild(existingScript);
                head.appendChild(node);
                break;
            case 'link':
                const existingLink = head.querySelector('link[rel="canonical"]');
                if (existingLink) {
                    existingLink.href = node.href;
                } else
                    head.appendChild(node);
                break;
        }

    }
    );

};

const addStyleScriptts = ( scrptStyle = [{ type:'', id:'', path:'', onLoadCallback:() => true }] ) => {
    const head = document.getElementsByTagName('head')[0];
    for(let item of scrptStyle){
        if(item.type !== undefined){
            let element = head.querySelector(`#${item.id}`);
            if(item.type === 'script'){
            if(!element) element =  document.createElement(item.type);
                element.src = item.path;
                element.onload = item.onLoadCallback;
            }else{
              if(!element) element =  document.createElement('link');
              element.rel='stylesheet'; 
              element.type = 'text/css';
              element.media = 'all';
              element.href= item.path;
            }
            element.id = item.id;
            head.appendChild(element);
        }
    }
};
 
const openPost = async (e) => {

    e.preventDefault();
    // Var needed
    const article = e.target.closest('article');
    isPostOpen = true;
    lastPostOpen = e;
    
    const postId = article.dataset.id;
    const postSlug = article.dataset.slug;
    const postTitle = article.dataset.title;
    
    getSeoHead();
    
    // show Load Spinner
    const articleTopOffset = article.getBoundingClientRect().top - document.body.getBoundingClientRect().top;
    const spinnerTag = addSpinner(articleTopOffset);

    // fetch post 
    const fetchUrl = `${window.location.origin}/wp-json/a309/v1/get-post/${postId}/user/${window.A309TH.current_user_id}`;

    const response = await fetch(fetchUrl, {
        headers: {
            'Content-Type': 'application/json'
        }
    });
    if(response.ok){
    const data = await response.json(); // parses JSON response into native JavaScript objects

    // Remove posts
    window.articleNodes = window.mainTagEL.querySelectorAll('article');
    for (let articleNode of window.articleNodes) {
        window.mainTagEL.removeChild(articleNode);
    }
 
    // change URL
    const state = {'post_id': postId, 'post_slug': postSlug, 'post_title': postTitle};
    history.pushState(state, postTitle, postSlug);
 
    // Change Title and meta
    updateHead(data['yoast_seo']);
    
    // Add Stylsheet and comment js
    addStyleScriptts([ {type:'script', id:'a309-comments-js', path:`${window.A309TH.theme_URI}/js/app_comments.js`,  onLoadCallback: () => {  /*window.A309TH.eventsOnComments */  }    },
                       {type:'script', id:'comment-reply-js', path:`${window.location.origin}/wp-includes/js/comment-reply.min.js`,  onLoadCallback: () => { window.addComment.init();  } },
                       {type:'script', id:'akismet-form-js', path:`${window.location.origin}/wp-content/plugins/akismet/_inc/form.js` },
                       {type:'style',  id:'a309-single-css', path:`${window.A309TH.theme_URI}/css/single.css` },

]);
    
    // Add Post to DOM
    addPost(data);
    window.scroll(0, 0);

    // Execute Plugin
    SyntaxHighlighter.highlight();
    window.A309TH.quicklinkListen();
    window.A309TH.initLightbox();
    window.a2a.init_all();
    }else{
    window.mainTagEL.prepend(window.A309TH.alertBox('error', `&#x26A0; error fetching post!`));
    isPostOpen = false;
    lastPostOpen = null;
    window.scroll(0, 0);
    }
  
    //Remove Spinner
    removeSpinner(spinnerTag);
    return false;
};

const backToPosts = () => {
    isPostOpen = false;
    if (window.articleNodes) {
        const spinnerEl = addSpinner(100);
        const mainNodes = [...window.mainTagEL.childNodes].filter(el => el instanceof HTMLElement && el.id !== 'loadingSpinner');
        updateHead(homeSeoHead);
        
        for(const node of mainNodes){
            node.parentElement.removeChild(node);
        }
        
         for (const articleNode of window.articleNodes) {
            window.mainTagEL.appendChild(articleNode);
        }
 
        window.articleNodes = null;
        removeSpinner(spinnerEl);
    }

};
 
    modifyEventForOpenPost(['.read-more', '.post-image-link', '.blog-post-title-link' ] , true);
    modifyEventForOpenPost(['.read-more', '.post-image-link', '.blog-post-title-link' ]);
    window.A309TH.delSimpleSpinner(spinner);
};

There’s a lot of code mostly because when we open a post we need to save our SEO head and the previous page in case the user hits the back button, we also need to set the new SEO head data when the post is opened, and we also need to add scripts/styles to the page that we didn’t need on the previous page, plus we need to initialize any JS that we loaded dynamically after it loads. Also, we’ll add a loader when we start and remove it after everything has loaded.

These days a load more button is less common since many websites use infinite scrolling, personally I don’t thing that replacing every loading more button with an infinite scroll is desirable even if we talk about feeds.

Here’s my load more posts function, you should also check if you have to call aditional JS to trigger plugins that you use:

const loadMorePosts = async () => {

    window.A309TH.delAlertBox();
    const showMorePostsParent = window.A309TH.postsRemoveShowMoreBtn.parentElement;
    window.A309TH.postsRemoveShowMoreBtn.disabled = true;

    const spinner = window.A309TH.addSimpleSpinner(showMorePostsParent);

    showMorePostsParent.prepend(spinner);

    const data = await fetchPosts(window.A309TH.fetchPostsOffset, 3);
    window.A309TH.fetchPostsOffset += 3;
    if (!data.httpError) {
        if (data.articles) {
            showMorePostsParent.insertAdjacentHTML('beforebegin', data.articles);
            SyntaxHighlighter.highlight();

        } else {
            showMorePostsParent.removeChild(window.A309TH.postsRemoveShowMoreBtn);
            showMorePostsParent.prepend(window.A309TH.alertBox('info', '&#x26A0; No more articles!'));
        }
    } else {
        showMorePostsParent.prepend(window.A309TH.alertBox('error', '&#x26A0; Error fetching request!'));
    }
    
      
    window.A309TH.postsRemoveShowMoreBtn.disabled = false;
    window.A309TH.delSimpleSpinner(spinner);

};