Automating Your Backend to Stay in Sync Effortlessly
Part of the series Empower and Enhance Your VitePress Blog with a Laravel API
At the end of the previous article, we created a posts table with a meta_id column. We have not used that column yet, so this article will connect it to the frontend.
VitePress generates a static website at build time, while comments are loaded dynamically for the post currently being viewed. To make that work, the frontend and backend need a stable way to identify the same post.
For example, the blog garabit has two articles with these slugs:
- Getting Started with Laravel (
getting-started-with-laravel) - Getting Started with Vue 3 and Vite (
getting-started-with-vue-3-and-vite)
We could use the slug as the identifier, but slugs sometimes change because of a typo, search-engine optimization, or a change in the content. Keeping an outdated slug only to preserve an identifier makes URLs less useful. We need a separate identifier that remains stable even when the slug changes.
The solution is to store a stable ID in each post's frontmatter. This ID identifies the post and does not change when its title, content, or slug changes.
The remaining question is how to send these IDs to the backend. Manual synchronization is error-prone and easy to automate, so the frontend will generate a meta.json file at build time. It will contain each post's ID and title. The backend will read that file and create or update the corresponding database records. The frontend can then fetch comments by using the ID in the post frontmatter, while the posts.id column remains an internal key used to link posts to comments.
The architecture is straightforward: VitePress writes the post metadata, and Laravel reads it to keep its records synchronized.
Generating the meta.json File
For this part, open the garabit project. We will create a genMeta function that reads all Markdown files, extracts each ID and title from the frontmatter, and writes them to dist/meta.json using VitePress's buildEnd hook. VitePress calls this hook after the rest of the build has finished, which makes it a good place to generate the file.
First, create a generators folder inside .vitepress. It will contain the functions called by the buildEnd hook.
Note
generators is only a naming choice. You can use any folder name you prefer.
mkdir .vitepress/generatorsCreate meta.ts in that folder. It will contain genMeta.
touch .vitepress/generators/meta.tsFinally, we can write our function:
import type { ContentData, SiteConfig } from 'vitepress'
import { writeFileSync } from 'node:fs'
import path from 'node:path'
import { createContentLoader } from 'vitepress'
export async function genMeta(config: SiteConfig) {
const posts = await createContentLoader('**/blog/**/*.md').load()
validatePosts(posts)
const meta = []
for (const item of posts) {
meta.push({
id: item.frontmatter.id,
title: item.frontmatter.title,
})
}
writeFileSync(path.join(config.outDir, 'meta.json'), JSON.stringify(meta.sort((a, b) => a.id - b.id), null, 2))
}
function validatePosts(items: ContentData[]) {
const ids = new Set<string>()
for (const item of items) {
if (!item.frontmatter.id) {
throw new Error(`Id is missing: ${item.url}`)
}
if (ids.has(item.frontmatter.id)) {
throw new Error(`Id is not unique: ${item.frontmatter.id} (${item.url}) (${items.find(i => i.frontmatter.id === item.frontmatter.id)?.url})`)
}
ids.add(item.frontmatter.id)
}
}The script has three steps. First, createContentLoader reads the Markdown files and parses their frontmatter. Next, we verify that every post has an ID and that IDs are unique. This matters because a post without an ID cannot participate in the comment system, while duplicate IDs would make two posts share comments. The validation throws an error and stops the build in either case.
We then create an array containing each post's ID and title and write it to dist/meta.json.
To run it, add genMeta to the buildEnd hook in .vitepress/config.ts:
import { defineConfig } from 'vitepress'
import { genMeta } from './generators/meta'
export default defineConfig({
// ...
buildEnd: async (config) => {
await genMeta(config)
},
})Finally, add a unique ID to each post:
---
id: 1 [!code ++]
title: Getting Started with Laravel
description:
date: 2024-10-14
------
id: 2
title: Getting Started with Vue 3 and Vite
description:
date: 2024-09-14
---When we build the frontend, VitePress will create meta.json in dist. It will contain each post's ID and title. Check it with:
npm run buildcat .vitepress/dist/meta.jsonThe output should look similar to this:
[
{
"id": 1,
"title": "Getting Started with Laravel"
},
{
"id": 2,
"title": "Getting Started with Vue 3 and Vite"
}
]If you see this file, the frontend half of the synchronization is complete.
Retrieving the Posts from the Backend
Next, the backend needs to read this file and synchronize the database. We will create an app:posts-sync command that fetches and parses meta.json, then inserts or updates each post.
Note
The title column is not used by the frontend or the API, but it makes database records easier to identify and can be useful in an admin panel.
Create the command with Artisan:
php artisan make:command PostsSyncThe command will be created in app/Console/Commands. Its job is simple:
- Fetch the
meta.jsonfile from the frontend - Insert (or update) each post in the database
Here is the implementation:
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Post;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Http;
final class PostsSync extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:posts-sync';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Synchronize backend posts with the frontend through the `meta.json` file.';
/**
* Execute the console command.
*/
public function handle(): void
{
/**
* @var array<array<string, int|string>>
*/
$response = Http::get(Config::get('app.frontend_url').'/meta.json')
->json();
$meta = collect($response)->map(
fn (array $post): array => [
'meta_id' => $post['id'],
'title' => $post['title'],
]
)->toArray();
Post::upsert($meta, uniqueBy: ['meta_id'], update: ['title']);
}
}The upsert method handles both cases. It inserts records that do not exist, using the unique meta_id key, and updates the title of an existing record.
The command must work in both local and production environments. Locally, the frontend may be served from localhost:5173 or localhost:4173 during a preview. In production, it is served from a public URL. We solve that difference with the APP_FRONTEND_URL environment variable, exposed through the frontend_url key in config/app.php.
APP_FRONTEND_URL=http://localhost:4173return [
// ...
'frontend_url' => env('APP_FRONTEND_URL', 'http://localhost:4173'),
// ...
];The code is then identical in both environments; only the environment variable changes.
During development, start the frontend preview server:
npm run build && npm run previewThen run the synchronization command:
php artisan app:posts-syncNote
Set the correct APP_FRONTEND_URL in .env before running the command.
If you see SQLSTATE[HY000]: General error: 1 no such table: posts, the posts table has not been created yet. Run:
php artisan migrateYou can now run the synchronization command repeatedly without creating duplicate records.
Testing the Command
The command is part of our application and we have to test it. To do this, we will mock the Http facade. This way, we can test the command without relying on the network. This is one of the rare cases where mocking is useful.
To mock the HTTP client, use Http::fake. It lets the test provide a predictable meta.json response without relying on the network.
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Http;
it('fetches posts meta', function () {
Http::fake([
Config::get('app.frontend_url').'/meta.json' => Http::response([
[
'id' => 1,
'title' => 'Post 1',
],
[
'id' => 2,
'title' => 'Post 2',
],
], 200),
]);
$this->artisan('app:posts-sync')
->assertExitCode(0);
Http::assertSentCount(1);
$this->assertDatabaseCount('posts', 2);
$this->assertDatabaseHas('posts', [
'meta_id' => 1,
'title' => 'Post 1',
]);
});
it('does not insert duplicates', function () {
Http::fake([
Config::get('app.frontend_url').'/meta.json' => Http::response([
[
'id' => 1,
'title' => 'Post 1',
],
[
'id' => 1,
'title' => 'Post 1',
],
], 200),
]);
$this->artisan('app:posts-sync')
->assertExitCode(0);
Http::assertSentCount(1);
$this->assertDatabaseCount('posts', 1);
$this->assertDatabaseHas('posts', [
'meta_id' => 1,
'title' => 'Post 1',
]);
});
it('updates the data based on the id', function () {
Http::fake([
Config::get('app.frontend_url').'/meta.json' => Http::response([
[
'id' => 1,
'title' => 'Post 1',
],
[
'id' => 1,
'title' => 'Post 1 Updated',
],
], 200),
]);
$this->artisan('app:posts-sync')
->assertExitCode(0);
Http::assertSentCount(1);
$this->assertDatabaseCount('posts', 1);
$this->assertDatabaseHas('posts', [
'meta_id' => 1,
'title' => 'Post 1 Updated',
]);
});The command tests live in a new Console folder. Add that folder to phpunit.xml so PHPUnit discovers them:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Console">
<directory>tests/Console</directory>
</testsuite>
</testsuites>
</phpunit>Because these tests access the database, update Pest so it refreshes the database before each test:
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Console', 'Http', 'Unit'); // [!code highlighter]Final Words
The frontend and backend now share a stable identifier for every post. VitePress generates the metadata, and Laravel can synchronize its records without manual database updates.
That gives us the bridge we need for the interactive features. Next, we will configure Sanctum, CORS, and CSRF protection, then send the first requests from VitePress to Laravel.
Thanks for reading! My name is Estéban, and I love to write about web development and the human journey around it.
I've been coding for several years now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.
If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.
I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below, it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!
Discussions
Add a Comment
You need to be logged in to access this feature.