Laravel 10 and Nuxt 3 — Setup SSR with Sanctum authentication from scratch

Artem Manchenkov
21 min readMar 25, 2023

--

Laravel API and Nuxt SSR integration

This article could be written by ChatGPT, but fortunately, Nuxt 3 is too new for it, so you still have a chance to read the human-written text!

UPD: since I published this article I used this solution in several projects and decided to create an NPM package with common reusable functionality, feel free to check it out and contribute — https://github.com/manchenkoff/nuxt-auth-sanctum.

0. Introduction

Nowadays, we could notice that monolithic application is not the most popular solution anymore, even for small projects. Usually, we would start with two different applications, one for the frontend side with a fancy and sometimes complex user interface and one or more for the backend, where our main logic is going on.

Of course, there are many pros and cons for that approach and basically, you can decide what will work for you depending on your particular case and requirements, but it is not a topic for this article.

Here we will take a look at the process of building a project based on two trendy frameworks — Laravel and Nuxt, and how to support Laravel Sanctum authentication with Nuxt SSR mode and some other crucial features.

Recently, Nuxt 3 was released, so its documentation is still being extended and a lot of modules and extensions are still under development, but it is quite promising already.

As the prerequisites, you should have the following list of tools installed on your local machine:

  • Docker to work with Laravel-related containers
  • Npm to work with the Nuxt project
  • Terminal or Command line to execute commands (examples in this article are written on macOS)

A lot of Laravel, Nuxt, and Vue basics will be omitted here, so you should be familiar with these technologies before reading the article.

Okay, enough for the introduction, let’s dive in!

1. Create a Laravel 10 application

To build our API we have to install Laravel and scaffold one of the default project templates provided by Laravel Breeze. If you want to migrate the existing monolithic application, it is also possible but might require a lot of changes in your codebase. The installation process of this framework is a very pleasant thing, you just need to run one command in your terminal:

curl -s "https://laravel.build/api" | bash

This command will create a new Laravel project in the directory called api. It will automatically trigger the Docker container-building process and write the message once it is ready. Relax, it may take some time…

Now we can go to this directory and run the application to make sure that everything works well, let’s do that by running the following command:

cd api && ./vendor/bin/sail up -d

Once containers are up and running, you can check that this URL returns a default Laravel page — http://localhost:80. Our next step is to scaffold the API template for the application to use Laravel Sanctum and remove all unnecessary files and directories like views, js, css.

Laravel Breeze is a package supposed to help developers to scaffold different starter kits of the framework with the ready-to-use implementation of the authentication. At the current moment, it provides 3 kits:

  • Blade templates
  • React / Vue
  • Next.js / API

To use Nuxt we should pick the 3rd option which will generate only API-related configuration and remove redundant parts of the project exactly as we wanted.

First, let’s install Breeze as a Composer dependency into our project:

./vendor/bin/sail composer require laravel/breeze --dev

Then we can scaffold the template by running these two commands:

./vendor/bin/sail artisan breeze:install api
./vendor/bin/sail artisan migrate

Once migrations are done, you should notice that the default Laravel page shows a JSON response instead of the HTML content. Besides that, you could also notice that some parts of Laravel configuration were changed as well, for example — middlewares, routing, controllers, etc.

Nice, it looks like we are ready to go back to the project root directory and switch to our frontend application setup.

2. Create a Nuxt 3 application

By default, Nuxt provides a quite simple way of project creation, so everything you need is to run the following command:

npx nuxi init frontend

Once the command execution is finished, you can go inside the new directory called frontend and install NPM dependencies:

cd frontend && npm install

To start Nuxt in a development mode you should use

npm run dev

Other entry points available you can find in the package.json file. Let’s start the application and make sure that everything works by checking Nuxt 3 default page at http://localhost:3000.

If you can see a page with the Nuxt logo and some welcome messages, then you are good! Our next step is to set up an additional layer for API interaction.

3. Setup a Nuxt Layer for the API

Nuxt framework provides support for both popular rendering modes — Server Side Rendering (SSR) and Client Side Rendering (CSR). However, there is no “pure SSR”, instead the framework provides support of Universal Rendering, which combines CSR and SSR by returning to the browser the fully rendered HTML document and later enabling interactivity with Vue.js.

This approach brings some pros and cons, of course, especially when we have to work with such functionality as authentication between our client and remote server with Nuxt in the middle. Check more details about Nuxt rendering modes here.

Regardless of the Nuxt 3 features we might want to provide the full API interaction functionality with any possible rendering mode and any backend framework.

To make our application more well-structured, we will put all this stuff in an additional Nuxt Layer.

Nuxt Layer is an isolated mini-application that will be loaded into your project automatically, thus you can encapsulate specific functionality from the actual application. For more details, you can check the official documentation about layers.

In our case, we will put in a separate layer all Laravel API-related services and configurations which results in a project structure like this:

frontend (project root directory)
- api
- composables
- useApi.ts
- useAuth.ts
- useUser.ts
- plugins
- client.ts
- nuxt.config.ts
- middleware
- auth.ts
- guest.ts
- unverified.ts
- verified.ts
- public
- .gitignore
- .npmrc
- app.vue
- nuxt.config.ts
- package-lock.json
- package.json
- README.md
- tsconfig.json

To create a new layer we can follow two different ways:

  1. manually create a new directory and necessary files
  2. run the npx command to scaffold the basic template

In my case, I am going to use the manual way since the default template provides the full structure of additional applications including its own package.json, app.vue, configuration files, etc.

First, we should create an empty layer in the root directory of our project:

mkdir api

After that, we can create a new nuxt.config.ts file in this directory and define our layer parameters. For now, we’ll leave it empty.

// api/nuxt.config.ts

export default defineNuxtConfig({
// …
});

To load this layer config into the main application automatically you have to register it in the main nuxt.config.ts by adding the following content:

// nuxt.config.ts

export default defineNuxtConfig({
extends: ["./api"],
});

To create all middleware files which will take care of our authentication checks, we will execute the following generator commands:

npx nuxi add middleware auth
npx nuxi add middleware guest
npx nuxi add middleware verified
npx nuxi add middleware unverified

Middleware should be a part of the top-level application because it is directly related to the main behavior, while other files should be inside of our layer, so let’s change the active directory by running:

cd api

Then we can generate some composables to use in our future Vue components to interact with an API layer:

npx nuxi add composable useApi
npx nuxi add composable useAuth
npx nuxi add composable useUser

And the last but not least, we should generate a plugin that will contain the definition of the custom HTTP client with all that “fancy” configuration to work with Laravel Sanctum:

npx nuxi add plugin client

All right, the files are ready and we are going to the actual implementation!

4. Implement API layer

By default, Laravel Sanctum proposes a way how to work with auth tokens in case of separate SPA applications, and one of the main things there — is to not use access tokens, and of course to not store them in the browser outside of the memory, in such places as Local Storage. Details about Laravel Sanctum can be found here and this is an article with some thoughts about safely storing your tokens:

Keep in mind that both your applications must share the same top-level domain. However, they may be placed on different subdomains.

Since Nuxt 3 uses ofetch as a main client for HTTP communication, we will leverage it to create our client with an additional configuration that will help us to pass necessary data from the API to the client and also to pass all headers back from the client to the server along with cookies.

First of all, we will declare some configuration values which will be used by our client and middleware handlers.

In the api/nuxt.config.ts we should describe some URLs of our Laravel API and provide names for headers and cookies:

// api/nuxt.config.ts

export default defineNuxtConfig({
runtimeConfig: {
public: {
api: {
// This is the URL of the API server.
baseUrl: "http://localhost:80",
// Endpoint to get the cookie.
cookieRequestUrl: "/sanctum/csrf-cookie",
// Endpoint to get the user.
userUrl: "/api/user",
// Key of the user object to keep in the state.
userKey: "user",
// Name of the cookie with the token.
csrfCookieName: "XSRF-TOKEN",
// Name of the header with the token.
csrfHeaderName: "X-XSRF-TOKEN",
// Name of the cookie from the API server.
serverCookieName: "set-cookie",
// Redirect to the login page if the user is not authenticated.
redirectUnauthenticated: true,
// Redirect to the verification page if the user is not verified.
redirectUnverified: true,
},
},
},
});

In the global config nuxt.config.ts we will also add a few parameters to define the base URL of the frontend application and a few endpoints for redirects:

// https://nuxt.com/docs/api/configuration/nuxt-config

export default defineNuxtConfig({
extends: ["./api"],

runtimeConfig: {
public: {
baseUrl: "http://localhost:3000",
homeUrl: "/dashboard",
loginUrl: "/login",
verificationUrl: "/verify-email",
},
},
});

Nuxt 3 supports different environments based on .env files, so it is very easy to replace runtime config by creating an env file in the root directory of the project and overriding each variable with the following syntax:

// .env

NUXT_PUBLIC_BASE_URL='http://localhost:3000' // will overwrite ‘baseUrl’ in nuxt.config.ts
NUXT_PUBLIC_API_BASE_URL='http://localhost:80' // will overwrite ‘baseUrl’ in api/nuxt.config.ts

Our next target is api/plugins/client.ts file where we will implement an HTTP client with cookies and headers interchanging support. First, we will import our runtime configuration and a few composables, one of which will be useUser to store an authenticated identity.

// api/plugins/client.ts

export default defineNuxtPlugin((nuxtApp) => {
const event = useRequestEvent();
const config = useRuntimeConfig();
const user = useUser();
const apiConfig = config.public.api;
});

As was mentioned before, our client will be based on ofetch which is used in other Nuxt composables like useFetch or useLazyFetch, so we will not lose the ability to work with refresh, pending and other fetching utilities.

Let’s define a FetchOptions object and create an instance of the client:

import { FetchOptions } from 'ofetch';

export default defineNuxtPlugin((nuxtApp) => {
const event = useRequestEvent();
const config = useRuntimeConfig();
const user = useUser();
const apiConfig = config.public.api;

const httpOptions: FetchOptions = {
baseURL: apiConfig.baseUrl,
credentials: 'include',
headers: {
Accept: 'application/json',
},
retry: false,

onRequest({ options }) {
// TODO
},

onResponse({ response }) {
// TODO
},

onResponseError({ response }) {
// TODO
},
};

const client: any = $fetch.create(httpOptions);
});

In this code sample, you may notice, that:

  • we added the import of FetchOptions from the ofetch package,
  • enabled credentials support for HTTP requests,
  • defined the base URL of the remote server,
  • defined default HTTP headers for the requests,
  • disabled retries (by default there is 1 additional attempt),
  • created an instance of the client.

Also, we prepared some placeholder functions to handle client events such as onRequest, onResponse, onResponseError. These are places where we will introduce our magic later. For more details, you can check the documentation about ofetch interceptors.

As the next steps, we will implement each of these interceptors to enrich our request data or to pass information from the server to the client.

Interceptor onRequest will be used to prepare additional request parameters depending on the process environment. In the case of SSR, we should pass all client headers to the API, including the CSRF token along with other cookies.

When we handle client requests during CSR, we need to request a CSRF cookie from the API and pass it as the X-XSRF-TOKEN header in the request, but usually only for secure methods like PUT, POST, etc. To understand better how it works, take a look at the official documentation of Laravel Santum SPA Authentication.

Let’s implement this logic accordingly by adding the following code:

async onRequest({ options }) {
if (process.server) {
options.headers = buildServerHeaders(options.headers);
}

if (process.client) {
const method = options.method?.toLocaleLowerCase() ?? '';

if (!SECURE_METHODS.has(method)) {
return;
}

options.headers = await buildClientHeaders(options.headers);
}
},

Note that we added async to the interceptor since we call buildClientHeaders with await.

As you probably noticed, we use some constant value SECURE_METHODS to determine when we should pass the headers collection, so let’s define it somewhere in the plugin file. I usually put this type of constant outside of the defineNuxtPlugin scope:

const SECURE_METHODS = new Set(['post', 'delete', 'put', 'patch']);

export default defineNuxtPlugin((nuxtApp) => {
// other code goes here ...

Nuxt provides an example of passing client cookies to the server in the documentation, so we will put the following code in the body of buildServerHeaders:

function buildServerHeaders(headers: HeadersInit | undefined): HeadersInit {
const csrfToken = useCookie(apiConfig.csrfCookieName).value;
const clientCookies = useRequestHeaders(['cookie']);

return {
...headers,
...(clientCookies.cookie && clientCookies),
...(csrfToken && { [apiConfig.csrfHeaderName]: csrfToken }),
Referer: config.public.baseUrl,
};
}

Since we are using Nuxt composables such as useCookie and useRequestHeaders we must define this function inside of the scope of defineNuxtPlugin. Now each request from our Nuxt application in SSR mode will contain our client’s cookies, Referer header, and CSRF token if they are present in the request.

The next step is to create a function for client headers processing, here is the body:

async function buildClientHeaders(
headers: HeadersInit | undefined
): Promise<HeadersInit> {
await $fetch(apiConfig.cookieRequestUrl, {
baseURL: apiConfig.baseUrl,
credentials: 'include',
});

const csrfToken = useCookie(apiConfig.csrfCookieName).value;

return {
...headers,
...(csrfToken && { [apiConfig.csrfHeaderName]: csrfToken }),
};
}

Here we call our API with a default $fetch function which also requires some basic configuration like baseURL and credentials support. As a response Laravel should set a cookie with a CSRF token inside, which we extract into the csrfToken variable and append to the request headers.

Interceptor onResponse also needs to be updated to pass all cookies from the API response back to the actual client even if it was handled by our Nuxt server application first.

This logic is much simpler, and we also have an example of this in the official documentation.

onResponse({ response }) {
if (process.server) {
const rawCookiesHeader = response.headers.get(
apiConfig.serverCookieName
);

if (rawCookiesHeader === null) {
return;
}

const cookies = splitCookiesString(rawCookiesHeader);

for (const cookie of cookies) {
appendHeader(event, apiConfig.serverCookieName, cookie);
}
}
},

We use a quite different approach from the official one because sometimes your cookies could have a more complex format and splitting by , may not work. Instead, there is a splitCookiesString function coming from the set-cookie-parsernpm package. To install it with the necessary TypeScript types support just run the following command:

npm i set-cookie-parser @types/set-cookie-parser --save-dev

Now you are able to import this function into our client.ts as well as appendHeader helper from h3 by adding this code snippet:

import { appendHeader } from "h3";
import { splitCookiesString } from "set-cookie-parser";

And we have one more Interceptor — onResponseError. It could be used for custom error handling, for example, we can do some action depending on the response status code or apply redirect rules if we get errors about an unauthenticated user. Also, here we might want to customize the error itself by casting it to a specific type or extracting only necessary information to pass to the client.

So, let’s define a few additional constants outside of our plugin scope to use for error handling:

const UNAUTHENTICATED_STATUSES = new Set([401, 419]);
const UNVERIFIED_USER_STATUS = 409;
const VALIDATION_ERROR_STATUS = 422;

The names of these constants are quite self-descriptive, we will use them to control the interaction flow of the API errors. Also, I want to be able to extract some Laravel-specific error context to throw it in the Nuxt application instead of basic HTTP or FetchError, to do so we will introduce one interface, which you could put in a separate file.

export default class ApiError extends Error {
message: string;
errors: string[];

constructor(data: any) {
super(data.message);
this.message = data.message;
this.errors = data.errors;
}
}

And we are going to put this code as an implementation of the interceptor:

async onResponseError({ response }) {
if (
apiConfig.redirectUnauthenticated &&
UNAUTHENTICATED_STATUSES.has(response.status)
) {
await navigateTo(config.public.loginUrl);

return;
}

if (
apiConfig.redirectUnverified &&
response.status === UNVERIFIED_USER_STATUS
) {
await navigateTo(config.public.verificationUrl);

return;
}

if (response.status === VALIDATION_ERROR_STATUS) {
throw new ApiError(response._data);
}
},

Note that we use async keyword again for the definition. This logic will provide the following behavior when an error occurs:

  • if redirect for unauthenticated users is enabled, we will call navigateTo to redirect to the login page URL configured in the global nuxt.config.ts,
  • if redirect for unverified users is enabled, we will redirect a user to the verification page according to the config file,
  • if we get a validation error (usually during a form submission), we will re-throw it as ApiError with useful response content only,
  • any other error will be handled with the default behavior.

All these conditions could be easily adjusted by the layer configuration which we defined in api/nuxt.config.ts file or even by environment variables in your .env file.

All right, our client is ready to use for our API interaction, so, it’s time to create more files 😁.

5. API Services implementation

Nuxt recommends the usage of useFetch() composable in each component which is a good solution but also causes a lot of issues when you have a more or less complex project, such as:

  • Code duplication: you always have to set up URLs, request methods, and pass headers like authorization (Bearer);
  • Complex maintenance: if you have custom error handling for a specific endpoint, you have to apply the same logic in a bunch of components;

That approach might limit your application and make you create custom composables, plugins, utils, etc. An alternative solution for those issues is to isolate the API or Data interaction into separate layers/services. One of the good examples is the Repository pattern and we will try to apply a similar approach in our application. Here is a good article by Leigh White where you can find more details about making your code better with this approach:

We will have a similar solution — a separate service for each entity or domain, or to represent a particular process like authentication.

As a first step, we will create a new directory inside our api layer — services. Then we will define the base class ApiServiceBase which will be used as an ancestor for any other service:

import { $Fetch } from 'ofetch';

export abstract class ApiBaseService {
private client: $Fetch;

constructor(client: $Fetch) {
this.client = client;
}

protected get call(): $Fetch {
return this.client;
}
}

Each of our services will receive an HTTP client as an argument in the constructor and will be able to call it by this.call(), which triggers the underlying $fetch() function from ofetch package. This approach allows us to pass any additional configuration (e.g. request parameters, body, method, headers, etc) inside of the scope of the service.

Also, we keep the support of the basic Nuxt composables like useAsyncData and useLazyAsyncData inside of the Vue components logic. In other words, we can use amazing features like pending and refresh with our API service classes instead of using useFetch() with a boilerplate code.

Next, we will create two API services to provide functionality for authentication endpoints and the default home controller of the Laravel application.

// api/services/ApplicationService.ts

import Application from "../models/Application";
import { ApiServiceBase } from "./ApiServiceBase";

export default class ApplicationService extends ApiServiceBase {
async info(): Promise<Application> {
return await this.call<Application>("/");
}
}
// api/services/AuthenticationService.ts

import User from "../models/User";
import { ApiServiceBase } from "./ApiServiceBase";

export default class AuthService extends ApiServiceBase {
async login(
email: string,
password: string,
remember = true
): Promise<any> {
return await this.call("/login", {
method: "post",
body: { email, password, remember },
});
}

async logout(): Promise<any> {
return await this.call("/logout", { method: "post" });
}

async register(
name: string,
email: string,
password: string,
password_confirmation: string
): Promise<any> {
return await this.call("/register", {
method: "post",
body: { name, email, password, password_confirmation },
});
}

async passwordForgot(email: string): Promise<any> {
return await this.call("/forgot-password", {
method: "post",
body: { email },
});
}

async passwordReset(
token: string,
email: string,
password: string,
password_confirmation: string
): Promise<{ status: string }> {
return await this.call("/reset-password", {
method: "post",
body: { email, token, password, password_confirmation },
});
}

async emailSendVerification(): Promise<any> {
return await this.call("/email/verification-notification", {
method: "post",
});
}

async user(): Promise<User> {
return await this.call("/api/user");
}
}

These services encapsulate our HTTP requests and provide a convenient way to call methods instead of assembling a request in the component’s code, also notice that the response from the server will be automatically cast to the necessary type or model. Let’s define those types as well in a new directory of our layer — api/models.

// api/models/Application.ts

export default interface Application {
Laravel: string;
}
// api/models/User.ts

export default interface User {
name: string;
email?: string;
email_verified_at?: Date;
}

And one more useful thing is to make our plugin easily accessible from everywhere in our application by introducing an interface with all available services. Create a new file in the services directory — ApiServiceContainer.ts with this content:

import ApplicationService from "./ApplicationService";
import AuthenticationService from "./AuthenticationService";

export interface ApiServiceContainer {
application: ApplicationService;
authentication: AuthenticationService;
}

Well done! We are ready to get back to our plugin implementation where we will create an object that provides all our services to the application. You just need to add these few lines of code to create a new instance of each service and define them as fields of the plugin object and implement the interface at the same time.

// api/plugins/client.ts

const api: ApiServiceContainer = {
application: new ApplicationService(client),
authentication: new AuthenticationService(client),
};

return { provide: { api } };

Now we can use our plugin in different places like composables by using this snippet:

const { $api } = useNuxtApp();

But before moving to the next parts of our application, let’s add one more thing to the plugin. Since we want to keep our user identity in the state of the application we need to request it from the API server when our requests are handled in SSR mode (if it wasn’t requested yet), otherwise, you will get an unauthenticated error.

By putting the following code block right before returning our plugin we will make sure that application has user identity loaded every time

if (process.server && user.value === null) {
await initUser(() => api.authentication.user());
}

The function initUser also needs to be defined inside of the plugin scope, so I put it next to our interceptor helper functions like buildServerHeaders.

async function initUser(getter: () => Promise<User | null>) {
try {
user.value = await getter();
} catch (err) {
if (
err instanceof FetchError &&
err.response &&
UNAUTHENTICATED_STATUSES.has(err.response.status)
) {
console.warn("[API initUser] User is not authenticated");
}
}
}

Make sure that you added all necessary imports and marked the function of the Nuxt plugin definition as async.

import { appendHeader } from 'h3';
import { FetchOptions, FetchError } from "ofetch";
import { splitCookiesString } from "set-cookie-parser";
import User from "../models/User";
import ApplicationService from "../services/ApplicationService";
import AuthenticationService from "../services/AuthenticationService";

// other code…

export default defineNuxtPlugin(async (nuxtApp) => {

// other code…

To wrap it up, here is the full content of our plugin file on Github:

6. Composables and middlewares

Finally, we can use all previously implemented stuff to make our composables and middleware handlers work with the user and authentication. The first one on our way will be useApi composable:

import { ApiServiceContainer } from "../services/ApiServiceContainer";

export const useApi = () => {
const { $api } = useNuxtApp();

return $api as ApiServiceContainer;
};

It’s just a syntax sugar to easily get access to our plugin from different Vue components.

The second one is useUser which will be used when we want to interact with the current user identity kept in our state.

import User from "../models/User";

export const useUser = () => {
const config = useRuntimeConfig();
const user = useState<User | null>(config.public.api.userKey, () => null);

return user;
};

And the last but most important one — useAuth which provides quick access to our main authentication functionality.

export const useAuth = () => {
const router = useRouter();
const config = useRuntimeConfig();
const { authentication } = useApi();
const user = useUser();

const isAuthenticated = computed(() => user.value !== null);

async function fetchUser(): Promise<any> {
user.value = await authentication.user();
}

async function login(
email: string,
password: string,
remember = true
): Promise<any> {
if (isAuthenticated.value === true) {
return;
}

await authentication.login(email, password, remember);
await fetchUser();

await router.push(config.public.homeUrl);
}

async function register(
name: string,
email: string,
password: string,
password_confirmation: string
): Promise<any> {
await authentication.register(
name,
email,
password,
password_confirmation
);
await fetchUser();

await router.push(config.public.homeUrl);
}

async function logout(): Promise<any> {
if (isAuthenticated.value === false) {
return;
}

await authentication.logout();
user.value = null;

await router.push(config.public.loginUrl);
}

return {
user,
isAuthenticated,
login,
register,
logout,
};
};

Each method will call the corresponding method in the authentication service and take some additional actions like reloading user identity from the API or redirecting to a different page on success response. As a return value, we have a User object, isAuthenticated boolean state, and 3 methods that could be used in Vue components (e.g Login or Registration pages).

Speaking of these methods’ usage we are going to update our middleware directory. Each middleware handler should interact with our plugin or API services to check the current state and take some actions.

This is an implementation of auth.ts middleware:

export default defineNuxtRouteMiddleware(() => {
const { isAuthenticated } = useAuth();
const config = useRuntimeConfig();

if (isAuthenticated.value === false) {
return navigateTo(config.public.loginUrl, { replace: true });
}
});

Simple yet useful action redirects our user to the login page if it is not authenticated.

The opposite action should be placed in our guest.ts middleware:

export default defineNuxtRouteMiddleware(() => {
const { isAuthenticated } = useAuth();
const config = useRuntimeConfig();

if (isAuthenticated.value === true) {
return navigateTo(config.public.homeUrl, { replace: true });
}
});

Our verified.ts middleware uses almost the same approach as auth.ts but with one more condition, which will redirect our users to the verification page if an email was not verified yet:

export default defineNuxtRouteMiddleware(() => {
const { user, isAuthenticated } = useAuth();
const config = useRuntimeConfig();

if (isAuthenticated.value === false) {
return navigateTo(config.public.loginUrl, { replace: true });
}

if (user.value?.email_verified_at === null) {
return navigateTo(config.public.verificationUrl, { replace: true });
}
});

Moving forward we also adding similar code into unverified.ts:

export default defineNuxtRouteMiddleware(() => {
const { user, isAuthenticated } = useAuth();
const config = useRuntimeConfig();

if (isAuthenticated.value === false) {
return navigateTo(config.public.loginUrl, { replace: true });
}

if (user.value?.email_verified_at !== null) {
return navigateTo(config.public.homeUrl, { replace: true });
}
});

It checks if a user is authenticated and triggers a redirect if an email was verified as well.

7. Integration with Vue components

To start with an easy example we will generate a new page to replace the Nuxt default one and also we will create a simple component that will call our ApplicationService.ts to get the current version of the Laravel API.

To generate a new page template run the following command:

npx nuxi add page index

Then we should update app.vue file to start picking pages instead of the default welcome component.

<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

To check that it works well you can start the dev server and go to http://localhost:3000, where you should see the content of the pages/index.vue instead of the Nuxt welcome page.

Let’s open the new page file and replace its content with the following code:

<script lang="ts" setup></script>

<template>
<div>Page: index</div>

<ApplicationInfo />
</template>

Now we need to create this ApplicationInfo Vue component by running this command:

npx nuxi add component ApplicationInfo

and put there this code sample:

<script lang="ts" setup>
const api = useApi();

const {
pending,
data: response,
refresh,
error,
} = await useLazyAsyncData("app", () => api.application.info());
</script>

<template>
<div>
<p>Laravel: {{ pending ? "Loading..." : response?.Laravel }}</p>

<template v-if="error">
<small>{{ error }}</small>
</template>
</div>

<button @click="refresh()">Refresh</button>
</template>

Now the main page will show the information from our Laravel API and we can refresh the content by clicking on Refresh button without an explicit call of our service again.

As an additional example, here is the login page component, which is shown only for guests thanks to middleware in the meta section.

<script lang="ts" setup>
definePageMeta({
middleware: ["guest"],
});

interface Credentials {
username: string;
password: string;
}

const { login } = useAuth();
const config = useRuntimeConfig();
const router = useRouter();

const credentials: Credentials = reactive({
username: "",
password: "",
});

const error = ref<string>("");

async function submit() {
try {
error.value = "";

await login(credentials.username, credentials.password, true);
router.push(config.public.homeUrl);
} catch (err) {
error.value = err as string;
}
}
</script>

<template>
<div>
<p>Page: login</p>

<form @submit.prevent="submit">
<small>{{ error }}</small>

<input
id="username"
v-model="credentials.username"
type="text"
name="username"
placeholder="Your username"
autocomplete="off"
/>
<input
id="password"
v-model="credentials.password"
type="password"
name="password"
placeholder="Your password"
autocomplete="off"
/>

<button type="submit">Login</button>
</form>

<NuxtLink to="/register" class="text-blue-500">
Register
</NuxtLink>

<NuxtLink to="/password-reset" class="text-blue-500">
Forgot password
</NuxtLink>
</div>
</template>

When you enter the correct credentials it will redirect you to the dashboard page because we provided this URL in our plugin configuration. To confirm that we successfully logged in, we should apply auth middleware to the page.

Since there is no dashboard page yet, we can create it like this:

npx nuxi add page dashboard

And also put there this code which will display the page only for authenticated users:

<script lang="ts" setup>
definePageMeta({
middleware: ["auth"],
});
</script>

<template>
<div>Page: dashboard</div>
</template>

<style scoped></style>

Now you can use this codebase as a starting point to create your applications and implement all other necessary pages like registration, password recovery, etc.

Conclusion

We have covered the process of creating two separate applications based on Laravel 10 and Nuxt 3 frameworks and integrating them to build a robust web application.

As a result, you got a project that supports Laravel Sanctum authentication and provides a convenient way of working with API with minimum boilerplate code.

It is important to note that the code samples provided in this article are way too not perfect and there is always room for improvement, so take this just as a rough example of a possible starting point. Feel free to introduce more interfaces, make it more SOLID, optimize some parts of the code, or even use a different approach in working with the API, it’s totally up to you!

All sources are available in this GitHub repository:

Hope you found this article useful! Feel free to ask me anything in the comments or on social media.

--

--