Streamlining User Authentication from Frontend to Backend
Part of the series Empower and Enhance Your VitePress Blog with a Laravel API
Now that the frontend can reach the Laravel API, we can use that connection to manage authentication throughout the application.
So far, we have tested requests in isolation. In this article, we will fetch the current user when the application starts, display the appropriate login or logout action, and implement logout with an optimistic update. The interface will update immediately while the API request runs, then recover if the request fails.
To manage this cleanly, we will combine Pinia with Pinia Colada, a data-fetching library built on top of Pinia and created by Eduardo, the creator of Pinia. It will give us shared cached state, request status, and the mutation hooks we need for optimistic updates.
Setting up the API
To support the frontend, we need two new routes in the Laravel application:
GET /api/user: Returns the authenticated user. The frontend calls it when the application starts to adapt the interface.POST /logout: Logs out the user and invalidates the current session when the frontend's logout button is clicked.
For each route, we will write tests, create a controller method, and register the route. This follows the same process as the previous articles.
Returning the User
Let's start with the first route. We will create a test to check if the user is authenticated and if the user data is returned correctly.
<?php
declare(strict_types=1);
use Illuminate\Testing\Fluent\AssertableJson;
it('requires authentification', function () {
$this->getJson(route('user.show'))
->assertUnauthorized();
});
it('returns the authenticated user', function () {
$user = App\Models\User::factory()->create();
$response = $this->actingAs($user)
->getJson(route('user.show'));
$response
->assertOk()
->assertJson(
fn (AssertableJson $json) => $json
->has(
'data',
fn (AssertableJson $json) => $json
->where('name', $user->name)
->where('username', $user->username)
->where('avatar', $user->avatar)
)
);
});These tests are similar to the comment tests. The first verifies authentication, and the second checks the user data in the JSON response.
Now, let's create the controller:
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Resources\UserResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class UserController
{
public function show(Request $request): JsonResource
{
return UserResource::make($request->user());
}
}This controller uses UserResource to transform the authenticated user into a JSON response. We created that resource in the comment-system article; it exposes the name, username, and avatar fields.
Finally, we need to register the route in the routes/api.php file:
<?php
declare(strict_types=1);
use App\Http\Controllers\Api\UserController;
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', [UserController::class, 'show'])
->name('user.show');
// ...
});That is enough to retrieve the current user.
Logging Out the User
Let's get started with the tests:
<?php
declare(strict_types=1);
use App\Models\User;
it('requires authentification', function () {
$this->postJson(route('logout'))
->assertUnauthorized();
});
it('destroys the session', function () {
$response = $this->actingAs(User::factory()->create())
->post(route('logout'))
->assertNoContent();
$this->assertGuest('web');
$response->assertNoContent();
});These tests follow the same patterns as the previous ones.
Now, let's create the controller:
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
final class AuthenticatedSessionController
{
public function destroy(Request $request): Response
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->noContent();
}
}The controller uses Laravel's Auth facade to log out the user. It then invalidates the session and regenerates the CSRF token. These steps help prevent session-fixation attacks.
Note
The controller returns an empty response because the frontend does not need a response body for logout.
Finally, we need to register the route in the routes/web.php file:
use App\Http\Controllers\Auth\AuthenticatedSessionController;
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
->middleware('auth:sanctum')
->name('logout');This route uses the auth:sanctum middleware even though it is defined in web.php. It does not return JSON and does not need to be versioned with the other API routes, so keeping it as a web route is reasonable.
In the config/cors.php config file, we also need to add the logout route to the paths array:
return [
'paths' => ['api/*', 'logout'],
// ...
];This allows the frontend to call the route from a different origin. Without it, the browser would block the request because of CORS.
Preparing the Frontend
The backend is ready, so we can move to the frontend.
We will retrieve the user when the application starts so the interface can react to the authentication state. Several components need this information. The header currently displays a login button; we will add a logout button for authenticated users and hide the login button in that case.
The footer may also need a login or logout link, and the comment system will need the same state to decide whether to display a login button or a comment form.
The simplest solution would be a shared composable:
const user = ref(null)
export function useUser() {
async function fetch() {
// Simulate fetching user data from the backend
user.value = await fetchUserData()
}
return {
user,
fetch,
}
}Note
We could also use the createSharedComposable from Vueuse to avoid having global state while keeping the same instance of the composable in all components.
We would then call fetch in the onMounted hook of the main component, Layout.vue in our case.
This solution works for a while, but it quickly becomes difficult to maintain. One component performs the fetch while many others consume the result. That may be manageable for one data type, but it becomes messy as the application grows.
We would also need boilerplate to manage mutation state. Logging out, creating a comment, and deleting a comment all need loading and error states. Repeating this code would either make components noisy or push us toward a collection of custom utilities.
We may also want optimistic updates. Logout is a good example: remove the user from state immediately, call the API, and restore the user if the request fails. Implementing that reliably and generically is not trivial.
To avoid this boilerplate, we will combine Pinia with Pinia Colada.
- Pinia is Vue's state-management library. A store centralizes state, getters, and actions, while the UI reacts to changes in that state.
- Pinia Colada is a data-fetching library built on top of Pinia. It manages requests and cache state, and it provides the hooks we need for optimistic updates.
Install Pinia and Pinia Colada in the Garabit project:
pnpm install pinia @pinia/coladaThen, we need to register them in our Vue application:
import { PiniaColada } from '@pinia/colada'
import { createPinia } from 'pinia'
export default {
Layout,
enhanceApp({ app }) {
app.use(createPinia())
app.use(PiniaColada)
},
} satisfies ThemeBoth packages are Vue plugins that add application-level functionality.
Remove the temporary request code from .vitepress/theme/Layout.vue; the shared API client will replace it.
Before writing the feature code, let us define the structure. In common/utils/api.ts, we will create a custom ofetch instance so every request uses the same host, headers, credentials, and CSRF handling. This is the code we introduced in the previous article.
Each feature will contain two folders and one API file:
queries: Contains requests that retrieve data.GET /api/useris a query.mutations: Contains requests that modify data.POST /logoutis a mutation.api.ts: Contains the low-level functions that make the requests. This file stays independent of the state-management library, which keeps the API layer easy to read and test.
These folders and the API file will exist for each feature. Today we will create the user feature. The next article will add a comments feature. This separation keeps related code together and makes it easier to find.
Extending ofetch
Create the custom ofetch instance in .vitepress/theme/common/utils/api.ts:
import { ofetch } from 'ofetch'
export const api = ofetch.create({
baseURL: import.meta.env.VITE_API_URL,
headers: {
Accept: 'application/json',
},
credentials: 'include',
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') || ''),
)
},
})
function getCookie(name: string) {
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`))
return match ? match[2] : null
}Auto-import all the Things
As in the series configuration, update .vitepress/config.mts to auto-import the new utilities, queries, mutations, and Pinia Colada composables.
const currentDir = dirname(fileURLToPath(import.meta.url))
const themeDir = resolve(currentDir, 'theme')
const utilsDir = resolve(themeDir, '**', 'utils')
const queriesDir = resolve(themeDir, '**', 'queries')
const mutationsDir = resolve(themeDir, '**', 'mutations')
const apiFile = resolve(themeDir, '**', 'api.ts')
export default defineConfig({
vite: {
plugins: [
AutoImport({
imports: [
{
from: '@pinia/colada',
imports: [
'defineQuery',
'useQuery',
'defineMutation',
'useMutation',
'useQueryCache',
],
},
],
dirs: [composablesDir, utilsDir, queriesDir, mutationsDir, apiFile],
}),
],
},
})This makes every function exported from the utils, queries, and mutations folders available throughout the application.
Creating the User Feature
Now, it's time to follow our plan:
Create the user feature:
mkdir -p .vitepress/theme/user/{queries,mutations}
touch .vitepress/theme/user/api.tsThen, we can write the code to fetch the user data and to logout the user in the .vitepress/theme/user/api.ts file:
export const fetchUser = () => api('api/user')
export const postLogoutUser = () => api('logout', { method: 'POST' })These functions abstract the raw API calls. The rest of the feature can use the explicit names fetchUser and postLogoutUser without knowing the request details.
Note
How do I name API functions? I use the fetch prefix for queries, followed by the resource name. For mutations, I use the HTTP verb followed by the resource name. This makes the functions easy to recognize and remember.
Now, we can create the query used to fetch the user data:
import type { User } from '@/user/types/user'
export const useUser = defineQuery(() => {
return useQuery<User>({
key: ['user'],
query: () => fetchUser().then(response => response.data),
enabled: typeof document !== 'undefined',
})
})We also need to define a new type for the user:
export interface User {
id: number
name: string
username: string
avatar: string
}Here is how the query works:
defineQuerydefines a reusable query within the Vue context.useQuerydescribes the key, request function, and state returned to components.keyidentifies the query in Pinia Colada's cache. WhenuseUseris called again, Pinia Colada can reuse a fresh cached result instead of sending another request.queryreturns the promise for the API request. We return the response'sdataproperty because Laravel resources wrap their payload indata.enabledlimits the request to the browser. We do not want to call the API while VitePress is generating the site.
We can now call useUser anywhere in the application. Components do not need to await the request; they consume its reactive state and let Vue update the interface when the data arrives.
Pinia Colada also deduplicates requests, refetches stale data when needed, exposes the cache, and provides hooks for optimistic updates.
Note
Deduplication matters because multiple components can call useUser on the same page. Pinia Colada makes one API request and shares the result between those calls.
Then, we can write the mutation to logout the user:
import type { User } from '@/user/types/user'
export const useLogoutUser = defineMutation(() => {
const queryCache = useQueryCache()
return useMutation({
mutation: () => postLogoutUser(),
onMutate: () => {
const oldUser = queryCache.getQueryData<User>(['user'])
queryCache.setQueryData(['user'], null)
queryCache.cancelQueries({ key: ['user'], exact: true })
return { oldUser }
},
onError: (_, __, { oldUser }) => {
if (oldUser) {
queryCache.setQueryData(['user'], oldUser)
}
},
onSettled: () =>
queryCache.invalidateQueries({ key: ['user'], exact: true }),
})
})This mutation uses an optimistic update. When the user clicks logout, onMutate removes the user from the cache immediately, so the interface swaps the logout button for the login button without waiting for the server. It returns the previous user so onError can restore it if the request fails. onSettled runs in either case and invalidates the user query, allowing Pinia Colada to refresh it when it is used again.
Update the header to call useUser and display the appropriate button:
<script lang="ts" setup>
const { state } = useUser()
</script>
<template>
<header>
<div>
<!-- ... -->
<div>
<!-- ... -->
<Button v-if="state.data" label="Logout" />
<Button v-else :href="loginUrl" label="Login" />
</div>
</div>
</header>
</template>Then, we can add our mutation:
<script lang="ts" setup>
const { mutate: logout } = useLogoutUser()
</script>
<template>
<header>
<div>
<!-- ... -->
<div>
<!-- ... -->
<Button v-if="state.data" label="Logout" @click="logout" />
</div>
</div>
</header>
</template>Try it now, but the logout action will not work yet. Our Button component is currently only a link:
<template>
<a :href :class="ui">
{{ props.label }}
</a>
</template>We need a component that renders an anchor when it receives href and a button when it receives onClick. Vue's built-in component element makes this possible.
First, allow the props to accept onClick:
export interface ButtonProps {
href?: string
label: string
color?: ButtonVariants['color']
class?: any
onClick?: () => void
}Then, we can use the built-in special component component:
<template>
<component
:is="props.href ? 'a' : 'button'"
:href="props.href"
:class="ui"
@click="props.onClick"
>
{{ props.label }}
</component>
</template>The important line is :is="props.href ? 'a' : 'button': it chooses an anchor or a button based on the props.
Now the complete flow works:
- Users can log in using GitHub
- Users can log out smoothly from the frontend
- Users can log in again
Conclusion
Authentication is now a reusable frontend feature. The application can fetch the current user, share the cached result across components, and update the interface optimistically when the user logs out.
This gives the comment system everything it needs to know whether someone is signed in and which controls to display. In the final article, we will use the same feature-based Pinia Colada structure to list, create, edit, and delete comments.
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.