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:

  1. GET /api/user: Returns the authenticated user. The frontend calls it when the application starts to adapt the interface.
  2. 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
<?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
<?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
<?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
<?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
<?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:

php
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:

php
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:

js
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:

bash
pnpm install pinia @pinia/colada

Then, we need to register them in our Vue application:

ts
import { PiniaColada } from '@pinia/colada'
import { createPinia } from 'pinia'

export default {
  Layout,
  enhanceApp({ app }) {
    app.use(createPinia())
    app.use(PiniaColada)
  },
} satisfies Theme

Both 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:

  1. queries: Contains requests that retrieve data. GET /api/user is a query.
  2. mutations: Contains requests that modify data. POST /logout is a mutation.
  3. 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:

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.

ts
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:

bash
mkdir -p .vitepress/theme/user/{queries,mutations}
touch .vitepress/theme/user/api.ts

Then, we can write the code to fetch the user data and to logout the user in the .vitepress/theme/user/api.ts file:

ts
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:

ts
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:

ts
export interface User {
  id: number
  name: string
  username: string
  avatar: string
}

Here is how the query works:

  • defineQuery defines a reusable query within the Vue context.
  • useQuery describes the key, request function, and state returned to components.
  • key identifies the query in Pinia Colada's cache. When useUser is called again, Pinia Colada can reuse a fresh cached result instead of sending another request.
  • query returns the promise for the API request. We return the response's data property because Laravel resources wrap their payload in data.
  • enabled limits 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:

ts
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:

vue
<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:

vue
<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:

vue
<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:

ts
export interface ButtonProps {
  href?: string
  label: string
  color?: ButtonVariants['color']
  class?: any
  onClick?: () => void
}

Then, we can use the built-in special component component:

vue
<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:

  1. Users can log in using GitHub
  2. Users can log out smoothly from the frontend
  3. 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.

PP

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!

Continue readingSimplifying Comment Querying and Editing with Pinia Colada

Reactions

Discussions

Add a Comment

You need to be logged in to access this feature.