Bridging the Gap: Connecting Your Frontend and Backend
Part of the series Empower and Enhance Your VitePress Blog with a Laravel API
With post synchronization in place, the next step is to connect the VitePress site to the Laravel API.
Because the two applications will be deployed separately, this involves more than sending a request to a URL. We need to configure CORS (Cross-Origin Resource Sharing) and CSRF (Cross-Site Request Forgery) protection, then ensure that the browser sends the credentials Laravel needs for session-based authentication.
The configuration can feel intimidating at first, but we will handle it one piece at a time. Laravel provides most of the pieces we need.
Tip
I recommend reading the linked articles if you want a deeper explanation of these concepts. It will make the configuration in this article easier to understand.
Installing Laravel Sanctum
The Laravel ecosystem provides Sanctum, which supports both API tokens and cookie-based authentication for Single Page Applications (SPAs).
To get started, let's install Sanctum:
php artisan install:apiThen, let's publish the cors configuration file:
php artisan config:publish corsCORS, or Cross-Origin Resource Sharing, is a browser security mechanism. By default, browsers prevent a page from making requests to a different origin. This protects users from malicious sites making unauthorized requests on their behalf.
Our frontend needs to communicate with an API hosted on another origin, so we must explicitly allow that origin in the API configuration.
Speaking of which, let's configure CORS to allow requests from our frontend:
<?php
declare(strict_types=1);
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => [
env('APP_FRONTEND_URL'),
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];We will discuss deployment later, but remember that we are creating two applications: the blog and the API that enriches it. They will be deployed separately, which is why the API must allow requests from the blog.
Note
We could deploy both applications behind the same domain with a reverse proxy, but that would add complexity that we do not need here.
Then, we need to configure Sanctum to allow requests from our frontend:
return [
'stateful' => explode(',', (string) env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost:4173,localhost:5173',
Sanctum::currentApplicationUrlWithPort()
))),
// ...
]The important change is adding the frontend's development origins to Sanctum's stateful domains.
A browser origin combines the protocol, hostname, and port. During development, localhost:5173 and localhost:8000 are different origins even though they share the same hostname.
Then, we need to add a middleware:
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi();
})
->create();This middleware tells Sanctum to authenticate requests with Laravel's session cookie instead of an API token. That is the approach we will use from the frontend.
Finally, we need to change our authentication middleware in the routes/api.php file. Actually, we use the default Laravel middleware which ensure that requests respect the Sanctum configuration like the stateful domains.
Route::middleware('auth:sanctum')->group(function () {
// Handle authenticated requests...
});Our backend is now ready to accept requests from the frontend.
Before moving to the frontend, finish a few backend changes.
- Update
storeinRegisteredViaGitHubControllerso it redirects the user to the frontend:
final class RegisteredViaGitHubController extends Controller
{
public function store(): RedirectResponse
{
// ...
return redirect(Config::get('app.frontend_url'));
}
}Update its test as well:
// ...
test('redirects to the frontend', function () {
fakeGitHubUser();
$this->get(route('auth.github.callback'))
->assertRedirect(config('app.frontend_url'));
});
// ...- Add a database seeder that generates sample comments:
Tip
Remove the existing contents of the seeder's run method before adding this code.
use App\Models\Post;
final class DatabaseSeeder extends Seeder
{
public function run(): void
{
Post::factory()
->count(10)
->hasComments(5)
->create();
}
}This creates 10 posts with five comments each, giving us data to use while testing the frontend.
- Recreate the database and run the migrations:
rm database/database.sqlite && touch database/database.sqlite && php artisan migrate --seedWe recreate the database because, until now, tests have used an in-memory SQLite database. The local database still contains the original scaffolded users table rather than the version we customized for GitHub authentication.
- Set
APP_FRONTEND_URLin.envand.env.exampleto the frontend's development URL:
APP_FRONTEND_URL=http://localhost:5173Until now, it pointed to http://localhost:4173, the VitePress preview server.
Connecting to the Backend from the Frontend
In this section, we will make the first requests from the frontend and verify that the connection works.
Warning
This is not the final version of the code. We will improve it in the next article.
Social Authentication
The first feature we can test is social authentication. We will add a header button that links to /auth/github/redirect on the backend. Because the backend URL will differ between development and production, we will store its base URL in an environment variable. The user will then be redirected to GitHub and, after authorizing the application, back to the frontend.
For the environment variable, we can use the VITE_ prefix to expose it to the frontend:
VITE_API_URL=http://localhost:8000Tip
You can safely add this variable to the .env.example file.
Now add the button to the header:
<script lang="ts" setup>
// ...
const loginUrl = `${import.meta.env.VITE_API_URL}/auth/github/redirect`
</script>
<template>
<header class="border-b-4 border-black bg-white p-8">
<!-- ... -->
<div class="flex items-center gap-6">
<!-- ... -->
<Button :href="loginUrl" label="Login" />
</div>
</header>
</template>We use VITE_API_URL to build the login URL. When Vite starts, it loads src/.env and exposes variables prefixed with VITE_ through import.meta.env.
We can also add types for this object to improve the development experience.
- Create a
vite-env.d.tsfile in the root of the project:
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}- Install Vite as a dev dependency:
npm install -D viteNote
The package manager pnpm will only make accessible packages that have been directly installed in the project. So even if VitePress uses Vite, we need to install it to have access to the types.
- Add the
vite-env.d.tsfile to thetsconfig.jsonfile:
{
"include": [
"./.vitepress/**/*",
"vite-env.d.ts"
]
}Now import.meta.env is typed, and TypeScript knows about VITE_API_URL.
Clicking the button should redirect us to GitHub for authorization and then back to the frontend. The first connection is working.
Creating a Comment
The second feature we can test is creating a comment. If this works, we can move to the next article with confidence that the backend and frontend can communicate.
To make requests, we will use ofetch. It wraps the native fetch API and adds JSON body serialization, retries, a base URL, and interceptors.
pnpm add ofetchWe will make the first request from Layout.vue as a temporary test.
First, let's try the basic request:
<script lang="ts" setup>
import { ofetch } from 'ofetch'
ofetch('/api/posts/1/comments', {
baseURL: import.meta.env.VITE_API_URL,
method: 'POST',
body: {
content: 'Hello world!',
},
headers: {
Accept: 'application/json',
}
})
</script>This request looks sufficient, but inspecting the browser's network tab shows a 419 response. The response preview contains:
{
"message": "CSRF token mismatch."
// ...
}The error is explicit: the request needs a CSRF token.
What is a CSRF token, and how do we handle it in an SPA?
A CSRF token helps prevent Cross-Site Request Forgery attacks. It gives the server a way to verify that a state-changing request comes from our application rather than from a malicious site acting on behalf of an authenticated user. In a server-rendered application, the token is embedded in the HTML. In an SPA, we need to retrieve and send it separately.
Laravel stores the token in the session and also places an encrypted value in a cookie that JavaScript on our site can read. We send that value in the X-XSRF-TOKEN header, and Laravel verifies it against the session.
Note
We cannot trust the cookie alone because the browser automatically sends it with requests. The explicit header is what proves that our JavaScript intentionally included the token.
Add the CSRF token to the request:
<script lang="ts" setup>
import { ofetch } from 'ofetch'
function getCookie(name: string) {
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`))
return match ? match[2] : null
}
ofetch('/api/posts/1/comments', {
// ...
onRequest: async ({ options }) => {
const method = options.method?.toUpperCase() || 'GET'
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
return
}
const xsrfToken = getCookie('XSRF-TOKEN')
if (!xsrfToken) {
await ofetch('/sanctum/csrf-cookie', {
baseURL: import.meta.env.VITE_API_URL,
credentials: 'include',
})
}
options.headers.set('X-Xsrf-Token', decodeURIComponent(getCookie('XSRF-TOKEN') || ''))
},
})
</script>This implementation uses an ofetch interceptor to add the CSRF token automatically. An interceptor is called at a stage in the request lifecycle. Here, onRequest modifies the outgoing request.
We only need the token for state-changing requests, so we skip safe methods. Adding it to them would not necessarily be harmful, but it could trigger unnecessary work.
Next, we read the token from the cookie. If it does not exist, we call Sanctum's /sanctum/csrf-cookie endpoint to create it.
Finally, we add the token to the request headers. With this helper in place, the frontend can make state-changing requests without repeating the CSRF setup each time.
We are making this helper complete now because we will reuse it in the next article.
Refresh the page. It still fails with a 419 status code. Inspect the request:
POST /api/posts/1/comments HTTP/1.1
Origin: http://localhost:5173
accept: application/json
x-xsrf-token: eyJpdiI6IktwK1I0UXA3K3F6Y3BvMEdKWHhGblE9PSIsInZhbHVlIjoidlgyMXlSbmlRMjhuNFNhODBLMHdUSkFJSDlQZzhObjJ2VDBIOWd1WjBrMzRZVWdqZldMZWxKcFlDMHB5ajZxZTY5bWR2amJLNTdhTFhtRHJqb3kxZlEvOFptNzdMSmZHU1FmeUw0d0pIRE1DWHRCSmZ1eFJ4aDBhN21qMW5KcDEiLCJtYWMiOiJmYjgyN2E1ZDAxMjMxZWZiZDk4MjU4MzAxOGE4OTYwOWY4ZmM1OGE3ZWMyYzM2Y2FjMTM4YWIwNGVmMDU1M2NhIiwidGFnIjoiIn0=The request contains the x-xsrf-token header, but no cookie header. That is a problem because the user is authenticated with a session cookie. Without the cookie, the backend cannot authenticate the user or compare the CSRF token with the session.
To fix this, add credentials: 'include' to the request. This tells the browser to include cookies on a cross-origin request, which is exactly what we need here.
<script lang="ts" setup>
ofetch('/api/posts/1/comments', {
// ...
credentials: 'include',
})
</script>Now the request works. 🎉
Final Thoughts
The connection is now working end to end. The frontend can call Laravel across origins, obtain a CSRF token when needed, and include the session cookie required for authenticated requests.
This setup gives us a reusable API foundation rather than a one-off request. Next, we will build a shared authentication layer with Pinia Colada so components can react to the current user and logout without duplicating request logic.
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.