Skip to content

Conquering VitePress Blog Pagination: My Journey Through Pitfalls and Solutions

Why did I choose VitePress to build my personal website and blog in the first place? It wasn't a spur-of-the-moment decision; I was mainly drawn to these aspects:

  1. Familiarity and Ease of Use: I had used VitePress before to write technical documentation, so I was familiar with its configuration, Markdown syntax, Vue component support, and its lightning-fast HMR (Hot Module Replacement). Using something familiar naturally saves time and effort.
  2. Need for Both Blog and Tutorials: My website isn't just for scattered blog posts; I also want to systematically present tutorial series (like Python for beginners, how to use FFmpeg). VitePress's file-directory-based routing and flexible sidebar are inherently suitable for this "document + blog" hybrid mode, making it more flexible than some pure blogging systems.
  3. Fast Deployment and Performance: VitePress is easy to install and deploy. Its Vite-based bundling is incredibly fast, and the resulting static website has excellent performance, which is exactly what I wanted.

However, as I wrote more and more blog posts, a problem emerged. VitePress's default sidebar is more suitable for well-structured documentation. When faced with a large number of blog posts arranged by time, it becomes a bit overwhelmed – no pagination, and with many articles, the sidebar can stretch on forever, exhausting readers. I needed to find a solution! So, I wondered if I could leverage VitePress's own features to create a dynamically loaded, time-sorted, and paginated list of recent articles on the homepage (index.md).

Seeking Help from AI: Almost Led Astray

Nowadays, who doesn't want to ask AI for technical solutions first? I thought that VitePress 1.6.3 was relatively new, so I should look for an AI that could connect to the internet. So, I tried Grok-3, which claims to be able to access the latest information, hoping it could directly generate usable code or reliable ideas for me.

The result? My communication with Grok-3 was a complete "disaster"! I clearly told it my requirements, directory structure, and VitePress version, but the solutions it provided were always full of errors and omissions:

  • It would confuse the APIs of VitePress 0.x and 1.x.
  • It suggested reinstalling VitePress 1.6.0 or implementing it using a custom theme, or even directly modifying the JavaScript code of VitePress's default theme in node_modules to solve a bunch of plugin startup problems.
  • The code snippets often had import errors, incorrect API calls, and illogical logic. In short, it always threw errors and couldn't start. Even when it finally started, the function wasn't implemented, and the homepage was blank.
  • Repeated communication and modification yielded little results. Finally, I encountered its usage frequency limit, asking me to wait an hour before using it again, which was really frustrating.

This ordeal made me realize that even if AI claims to be connected to the internet, its depth of understanding and accuracy for specific frameworks (especially those that are rapidly updating) are still questionable. Relying too much on it can waste effort on the wrong path.

I had no choice but to try Gemini 2.5 Pro exp. And surprisingly, communicating with Gemini was much smoother! After describing my requirements, it quickly provided a solution based on VitePress 1.6.x's core feature, createContentLoader. I encountered a small problem with theme resolution (@theme/index), and when I pasted the error message, Gemini immediately pointed out the problem (no custom theme entry file was created) and told me the correct fix (create a theme/index.js to inherit the default theme).

This back-and-forth clearly demonstrated the difference in the ability of different AI models to handle specific framework problems. Gemini's understanding of VitePress 1.x was obviously more thorough, and the solution it provided was more in line with the framework's best practices.

A Glimmer of Hope: createContentLoader to the Rescue

The final success was entirely due to a "magic weapon" that comes with VitePress 1.x – createContentLoader. It allows us to load and process Markdown files during the build process, and then package the processed data for the front-end Vue components to use directly.

How does it work specifically? Let's break it down into steps:

  1. Loading Data (.vitepress/theme/posts.data.js): This is the core! Create a posts.data.js file and use createContentLoader to do the work:

    javascript
    // .vitepress/theme/posts.data.js
    import { createContentLoader } from 'vitepress'
    import matter from 'gray-matter' // This library is used to parse frontmatter and content
    
    // The specific implementation of the createExcerpt function is omitted here. It mainly extracts plain text excerpts.
    function createExcerpt(content, maxLength) {
        // ... Implement logic to remove Markdown/HTML tags and truncate ...
        // Simple example: remove tags and truncate to the first maxLength characters
        const plainText = content.replace(/<[^>]*>/g, '').replace(/#+\s/g, ''); 
        return plainText.slice(0, maxLength) + (plainText.length > maxLength ? '...' : '');
    }
    
    
    export default createContentLoader('**/*.md', { // Match all Markdown files
      includeSrc: true, // Need to load source file content to extract excerpts
      ignore: ['index.md', '**/node_modules/**', '.vitepress/**'], // Exclude non-article files
      transform(rawData) {
        return rawData
          // Filter out md files with incomplete frontmatter or non-target content
          .filter(({ frontmatter, src }) => frontmatter && frontmatter.date && frontmatter.title && src) 
          .map(({ url, frontmatter, src }) => {
            // Use gray-matter to separate frontmatter and pure Markdown content
            const { content } = matter(src); 
            return {
              title: frontmatter.title,
              url,
              // Ensure date is a Date object for easy sorting
              date: new Date(frontmatter.date), 
              // Call our written function to generate an excerpt
              excerpt: createExcerpt(content, 200), 
            }
          })
          // Sort by date in descending order, the newest first
          .sort((a, b) => b.date.getTime() - a.date.getTime()); 
      }
    })
    • '**/*.md':Tells VitePress to scan Markdown files in all directories.
    • ignore:Excludes files that don't need to be treated as articles, such as the homepage, node_modules, and .vitepress directories.
    • includeSrc: true:This is very important; it must be set to true to get the original Markdown content, which is used to generate excerpts.
    • The transform function is where the magic happens:
      • First, filter to ensure that the article has the title and date frontmatter fields we need.
      • Then, map to process each file that meets the criteria: use gray-matter to separate frontmatter and content, and then call the createExcerpt function to generate an excerpt (you have to write this function yourself; the logic is to remove Markdown/HTML tags and truncate the first 200 characters).
      • Finally, return an array of objects containing title, url, date, excerpt information.
      • Don't forget to sort by date date in descending order, so the latest articles are at the top.
  2. Display Component (.vitepress/theme/components/LatestPosts.vue): Create a Vue component to consume the processed data above and implement pagination:

    vue
    <script setup>
    import { ref, computed } from 'vue'
    // Import the data generated at build time, make sure the path is correct
    import { data as allPosts } from '../posts.data.js' 
    
    const postsPerPage = ref(10); // How many posts to display per page
    const currentPage = ref(1); // Which page are we on
    
    // Calculate the total number of pages
    const totalPages = computed(() => Math.ceil(allPosts.length / postsPerPage.value));
    
    // Calculate which articles should be displayed on the current page
    const paginatedPosts = computed(() => {
      const start = (currentPage.value - 1) * postsPerPage.value;
      const end = start + postsPerPage.value;
      return allPosts.slice(start, end);
    });
    
    // Function to switch page numbers
    function changePage(newPage) {
      if (newPage >= 1 && newPage <= totalPages.value) {
        currentPage.value = newPage;
      }
    }
    
    // Format the date to make it look nicer
    function formatDate(date) {
      if (!(date instanceof Date)) date = new Date(date);
      return date.toLocaleDateString(); // Or use a more detailed format
    }
    </script>
    
    <template>
      <div class="latest-posts">
        <ul>
          <li v-for="post in paginatedPosts" :key="post.url">
            <div class="post-item">
                <a :href="post.url" class="post-title">{{ post.title }}</a>
                <span class="post-date">{{ formatDate(post.date) }}</span>
                <p v-if="post.excerpt" class="post-excerpt">{{ post.excerpt }}</p> 
            </div>
          </li>
        </ul>
    
        <!-- Pagination Control -->
        <div class="pagination">
          <button @click="changePage(currentPage - 1)" :disabled="currentPage === 1">
            Previous Page
          </button>
          <span>Page {{ currentPage }} / {{ totalPages }}</span>
          <button @click="changePage(currentPage + 1)" :disabled="currentPage === totalPages">
            Next Page
          </button>
        </div>
      </div>
    </template>
    
    <style scoped> 
    /* Add some styles here to make the list look better */
    .latest-posts ul { list-style: none; padding: 0; }
    .post-item { margin-bottom: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 1em; }
    .post-title { font-size: 1.2em; font-weight: bold; display: block; margin-bottom: 0.3em; }
    .post-date { font-size: 0.9em; color: #888; margin-bottom: 0.5em; display: block;}
    .post-excerpt { font-size: 1em; color: #555; line-height: 1.6; margin-top: 0.5em; }
    .pagination { margin-top: 2em; text-align: center; }
    .pagination button { margin: 0 0.5em; padding: 0.5em 1em; cursor: pointer; }
    .pagination button:disabled { cursor: not-allowed; opacity: 0.5; }
    .pagination span { margin: 0 1em; }
    </style>
    • Use import { data as allPosts } from '../posts.data.js' to import all the processed article data.
    • Use Vue's ref (reactive variable) and computed (computed property) to implement pagination logic, and the page will automatically update when the data changes.
  3. Use it on the Homepage (index.md): In your homepage Markdown file (index.md), use this component like a normal HTML tag:

    markdown
    ---
    layout: home # Make sure your layout supports Vue components; the default home layout usually does
    ---
    <script setup>
    // Import the component we just wrote
    import LatestPosts from './.vitepress/theme/components/LatestPosts.vue' 
    </script>
    
    # My Blog Homepage
    
    Welcome to my little site...
    
    ## Latest Articles
    
    <LatestPosts /> <!-- Put the component where you want to display the list -->
    
    Other content you want to put...
  4. Fix the Theme Error (.vitepress/theme/index.js): How to solve that annoying @theme/index error? It's actually very simple; as long as you create the .vitepress/theme directory (even if it's just to put posts.data.js or components), VitePress needs a theme entry file. You just need to create a theme/index.js and simply inherit the default theme:

    javascript
    // .vitepress/theme/index.js
    import DefaultTheme from 'vitepress/theme'
    // Just like that, export an object that inherits from the default theme
    export default { ...DefaultTheme }

    This way, VitePress knows how to load the theme, and your custom posts.data.js and components can work properly.

Review and Summary: Pitfalls and Lessons Learned

This experience was quite rewarding, and I've summarized a few lessons:

  • createContentLoader is the Key: For VitePress 1.x, this is the officially recommended, high-performance, and convenient way to process content data during build time.
  • Standardized Frontmatter is Important: The processing logic in posts.data.js largely depends on the title and date fields in the Markdown file header. Therefore, when writing articles, these things must be standardized, especially the format of date, otherwise data processing will go wrong, and the function won't work.
  • Don't Mess Up the Paths: When importing other files in .vue files or data.js, the relative paths must be correct, otherwise the files won't be found.
  • Excerpt Generation Involves Trade-offs: Using regular expressions to strip tags and generate plain text excerpts is a simple and fast method, but it may not be perfect for particularly complex Markdown. However, it's usually good enough for quick previews.
  • Beware of the Theme Entry "Pitfall": As long as you touch the .vitepress/theme directory, even if you only add a file, remember to provide theme/index.js, even if it simply inherits the default theme. Otherwise, it will throw an error on startup.
  • AI is a Helpful Tool, But Don't Treat it as a "God": AI can definitely help, but it can't completely replace your understanding of the framework. When you encounter problems, you still need to learn to read the official documentation and debug yourself. Also, choosing the right AI model is crucial; Gemini was much more helpful than Grok this time.

Finally, I managed to add the desired paginated article list to the VitePress website homepage, retaining both the structured navigation of a documentation site and satisfying the needs of a blog article stream. Goal achieved (this blog website uses this plugin)!

Although the process had some twists and turns, it gave me a deeper understanding of VitePress and also allowed me to experience the performance differences between different AI tools in practice, which was quite worthwhile!