In this article, I will demonstrate how to authenticate the Qwik web application using a Laravel-powered API backend. Before you proceed ensure that your Laravel API backend is ready for SPA authentication. I covered that in Preparing a Laravel API backend for SPA authentication using Sanctum. The following video show what we will be building.
What is Qwik
Qwik is a JavaScript framework that solves problems other frameworks can't solve. To truly appreciate Qwik's uniqueness, it's important to understand the shortcomings of current frameworks, which is hydration. With hydration, the frameworks must download all component code linked to the current page and execute associated templates to rebuild listener locations and internal components. Hydration is, therefore, expensive.
Qwik, on the other hand, uses no hydration and rather uses a technique known as Resumability. Qwik app serialises a page into HTML, pauses execution on the server, and the application can resume execution on the client without having to replay and re-download. Qwik apps have instant-on startup performance. They also have the same initial javascript regardless of the complexity of the application.
With Qwik, you can build applications that are instantly interactive, fast, and scalable - all with Developer Experience like that of React or Vue. You can learn more from the Qwik website.
Why not Just use Auth.js
Auth.js is an open-source authentication solution for web applications. Auth.js eliminated the complexity of authenticating with OAuth providers, databases, and email magic links, among others. While the library is great and has Qwik support, it has poor support for credentials authentication (username and password) and does not play well with Laravel.
My first attempt was to generate a Sanctum API token and store it in an Auth.js JSON Web Token (JWT) encrypted cookie. I also implemented session-based authentication using Auth.js. Both methods proved futile. User experience (UX), when credentials failed, could have been better; there was no fluent way of authenticating users after registration. I
I ended up going the vanilla way of authentication, and that is what I will cover in this article.
Install new qwik application
Install a new empty qwik application, if you do not have one already
npm create qwik@latest
Create vite
environment variables
touch .env.local
Then add the following public build-time variables.
PUBLIC_API_ENDPOINT=http://localhost:8000
# PUBLIC_API_ENDPOINT=http://api.example.com -> production
PUBLIC_SESSION_NAME=laravel_api_session
Note:
PUBLIC_API_ENDPOINT
MUST match LaravelAPP_URL
whilePUBLIC_SESSION_NAME
MUST match LaravelSESSION_NAME
. The variables are prefixed withPUBLIC_
so that we can access their value from the client side (browser).
Install modular form and valibot
Modular Forms is a type-safe Javascript form library that we will use to build our login form.
npm install -D @modular-forms/qwik
Next we add Validbot a type safe type safe data validation. We will use Validbot to minimise hits on out API endpoint
npm install -D valibot
Install Tailwindcss (optional)
Tailwind is a CSS framework that will help up add basic styles to our login page. Tailwindcss is optional and you can use any other framework.
npm run qwik add tailwind
Add login page
Create a new login route in your Qwik application by running the following command
npm run qwik new /login
Form here we are going to edit src/routes/login/index.tsx
.Start by importing the api Laravel API endpoint to the client (browser).
const endpoint = import.meta.env.PUBLIC_API_ENDPOINT
Define the login form. The schema should include the error messages you wish to show your users.
import type { Input} from "valibot"
import {email, maxLength, minLength, object, string} from "valibot"
const LoginSchema = object({
email: string([
minLength(1, 'Please enter an email address.'),
email('Enter a valid email e.g [email protected].'),
maxLength(256, 'Email too long. Enter an shorter alternative email.'),
]),
password: string([minLength(1, 'Please enter a password.')]),
// Add more fields
})
type LoginForm = Input<typeof LoginSchema>;
You can add more fields in the scheme if required. Next, set the initial value of the login form and link the validation.
import { useForm, valiForm$ } from '@modular-forms/qwik'
// ...
export default component$(() => {
const [loginForm, { Form, Field }] =
useForm <
LoginForm >
{
loader: { value: { email: '', password: '' } },
validate: valiForm$(LoginSchema),
}
// ...
})
Next, import useNavigation
we will use it to re-direct the user once authenticated.
import { useNavigate } from '@builder.io/qwik-city'
export default component$(() => {
// ...
const nav = useNavigate()
// ....
})
Add an empty form submit handler
import type { QRL} from "@builder.io/qwik"
import type {SubmitHandler} from "@modular-forms/qwik"
import {component$, $} from "@builder.io/qwik"
// ...
export default component$(() => {
const handleSubmit: QRL<SubmitHandler<LoginForm>> = $(async (values) => {
// TODO:
})
})
Let us add the login form. Refer to modular form documentation if you need more details.
export default component$(() => {
//....
return (
<section class="bg:white flex min-h-screen items-center justify-around md:bg-gray-200">
<div class="grid w-full max-w-sm space-y-6 p-4 md:bg-white md:p-8">
<h1 class="text-4xl font-medium">Login</h1>
<Form onSubmit$={handleSubmit}>
<Field name="email">
{(field, props) => (
<div class="mb-5">
<label
for="email"
class="mb-2 block text-sm font-medium text-gray-900 dark:text-white"
>
Email address
</label>
<input
{...props}
value={field.value}
type="email"
autoComplete="current-username"
autoFocus
id="email"
class={[
'block w-full rounded-lg border bg-gray-50 p-3 text-sm text-gray-900 ',
field.error
? 'border-red-600'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500',
]}
/>
{field.error && (
<p class="mt-2 text-sm text-red-600 dark:text-red-500">{field.error}</p>
)}
</div>
)}
</Field>
<Field name="password">
{(field, props) => (
<div class="mb-5">
<label
for="email"
class="mb-2 block text-sm font-medium text-gray-900 dark:text-white"
>
Password
</label>
<input
{...props}
value={field.value}
type="password"
autoComplete="current-password"
id="password"
class={[
'block w-full rounded-lg border bg-gray-50 p-3 text-sm text-gray-900 ',
field.error
? 'border-red-600'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500',
]}
/>
{field.error && (
<p class="mt-2 text-sm text-red-600 dark:text-red-500">{field.error}</p>
)}
</div>
)}
</Field>
<button
class={[
'mb-2 me-2 rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium text-white',
'hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300',
]}
disabled={loginForm.submitting}
>
{!loginForm.submitting && <span>Login</span>}
{loginForm.submitting && <span>Logging in ... </span>}
</button>
</Form>
</div>
</section>
)
})
You should now be having a styled form that can validate input using the rules we specified.

Add login functionality
When you submit the form the values are validated and passed to handleSubmit
function we created. First We need to make a GET
request to the ${endpoint}/sanctum/csrf-cookie
endpoint to initialize CSRF protection for the application. Since we will user fetch
in favour of axios
, we enable { credentials: 'include' }
to ensure the XSRF-TOKEN
received from Laravel is saved in the browser cookies.
const handleSubmit: QRL<SubmitHandler<LoginForm>> = $(async (values) => {
await fetch(`${endpoint}/sanctum/csrf-cookie`, { credentials: 'include' })
const token = await getBrowserCookieValue('XSRF-TOKEN')
// ....
});
We then read the value of XSRF-TOKEN
from the browser using the helper function getBrowserCookieValue
const getBrowserCookieValue = $(function getBrowserCookieValue(cname: string) {
const name = cname + '='
const decodedCookie = decodeURIComponent(document.cookie)
const ca = decodedCookie.split(';')
for (let i = 0; i < ca.length; i++) {
let c = ca[i]
while (c.charAt(0) == ' ') {
c = c.substring(1)
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length)
}
}
return ''
})
If the sanctum/csrf-cookie
request was successful, token
should now have a the value of XSRF-TOKEN
similar to this example below
eyJpdiI6InhTS24xMkNKNENUdlRsT2JMK2QvUVE9PSIsI...TI4OTk3YzZmZGI2YmYxZTk4NjZlIiwidGFnIjoiIn0=
Next we make a POST request to ${endpoint}/login
attaching the token as X-XSRF-TOKEN
header. We also submit the values of email
and password
in the request.
const handleSubmit: QRL<SubmitHandler<LoginForm>> = $(async (values) => {
await fetch(`${endpoint}/sanctum/csrf-cookie`, { credentials: 'include' })
const token = await getBrowserCookieValue('XSRF-TOKEN')
const response = await fetch(`${endpoint}/login`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-XSRF-TOKEN": token,
},
body: JSON.stringify(values),
});
// ....
});
if the response is successful, we re-direct the user to the intended page, e.g dashboard.
import { setError } from "@modular-forms/qwik";
// ....
const handleSubmit: QRL<SubmitHandler<LoginForm>> = $(async (values) => {
// ....
if (response.ok) {
// Change the page
nav('/dash', {
replaceState: true,
forceReload: true,
})
} else {
switch (response.status) {
case 422:
const json = await response.json()
const errors = json.errors
Object.keys(errors).forEach((key) => {
setError(loginForm, key as 'email' | 'password', errors[key])
})
break
default:
throw new Error('Sorry, there was an error when logging in. Refresh the page and try again.')
}
}
});
});
If the response fails with 422
, we display Laravel validation errors in the form. You can handle the rest of the errors as you deem fit.

Final login form
We get a functional login form if we put all the building blocks together.
import type { QRL } from "@builder.io/qwik";
import { component$, $ } from "@builder.io/qwik";
import type { Input } from "valibot";
import { email, maxLength, minLength, object, string } from "valibot";
import { type DocumentHead, useNavigate } from "@builder.io/qwik-city";
import type { SubmitHandler } from "@modular-forms/qwik";
import { setError } from "@modular-forms/qwik";
import { useForm, valiForm$ } from "@modular-forms/qwik";
// noinspection JSUnresolvedReference
const endpoint = import.meta.env.PUBLIC_API_ENDPOINT;
const LoginSchema = object({
email: string([
minLength(1, "Please enter an email address."),
email("Enter a valid email e.g [email protected]."),
maxLength(256, "Email too long. Enter an shorter alternative email."),
]),
password: string([minLength(1, "Please enter a password.")]),
});
type LoginForm = Input<typeof LoginSchema>;
export default component$(() => {
const [loginForm, { Form, Field }] = useForm<LoginForm>({
loader: { value: { email: "", password: "" } },
validate: valiForm$(LoginSchema),
});
const nav = useNavigate();
const getBrowserCookieValue = $(function getBrowserCookieValue(
cname: string,
) {
const name = cname + "=";
const decodedCookie = decodeURIComponent(document.cookie);
const ca = decodedCookie.split(";");
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == " ") {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
});
const handleSubmit: QRL<SubmitHandler<LoginForm>> = $(async (values) => {
await fetch(`${endpoint}/sanctum/csrf-cookie`, { credentials: "include" });
const token = await getBrowserCookieValue("XSRF-TOKEN");
const response = await fetch(`${endpoint}/login`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-XSRF-TOKEN": token,
},
body: JSON.stringify(values),
});
if (response.ok) {
nav("/", {
replaceState: true,
forceReload: true,
});
} else {
switch (response.status) {
case 422:
const json = await response.json();
const errors = json.errors;
Object.keys(errors).forEach((key) => {
setError(loginForm, key as "email" | "password", errors[key]);
});
break;
default:
// noinspection ExceptionCaughtLocallyJS
throw new Error(
"Sorry, there was an error when logging in. Refresh the page and try again.",
);
}
}
});
return (
<section class="bg:white flex min-h-screen items-center justify-around md:bg-gray-100">
<div class="grid w-full max-w-sm space-y-6 p-4 md:bg-white md:p-8">
<h1 class="text-4xl font-medium">Login</h1>
<Form onSubmit$={handleSubmit}>
<Field name="email">
{(field, props) => (
// @ts-ignore
<div class="mb-5">
<label
for="email"
class="mb-2 block text-sm font-medium text-gray-900 dark:text-white"
>
Email address
</label>
<input {...props}
value={field.value}
type="email"
autoComplete="current-username"
autoFocus
id="email"
class={[
"block w-full rounded-lg border bg-gray-50 p-3 text-sm text-gray-900 ",
field.error
? "border-red-600"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500",
]}
/>
{field.error && (
<p class="mt-2 text-sm text-red-600 dark:text-red-500">
{field.error}
</p>
)}
</div>
)}
</Field>
<Field name="password">
{(field, props) => (
// @ts-ignore
<div class="mb-5">
<label
for="email"
class="mb-2 block text-sm font-medium text-gray-900 dark:text-white"
>
Password
</label>
<input {...props}
value={field.value}
type="password"
autoComplete="current-password"
id="password"
class={[
"block w-full rounded-lg border bg-gray-50 p-3 text-sm text-gray-900 ",
field.error
? "border-red-600"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500",
]}
/>
{field.error && (
<p class="mt-2 text-sm text-red-600 dark:text-red-500">
{field.error}
</p>
)}
</div>
)}
</Field>
<button class={[
"mb-2 me-2 rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium text-white",
"hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300",
]}
disabled={loginForm.submitting}
>
{!loginForm.submitting && <span>Login</span>}
{loginForm.submitting && <span>Logging in ... </span>}
</button>
</Form>
</div>
</section>
);
});
export const head: DocumentHead = {
title: "Login",
meta: [
{
name: "description",
content: "Welcome back to my application",
},
],
};
Adding authentication middleware
It is a common practice to secure some page such that they are only accessible to authenticated user. To implement this feature in Qwik, we need a middleware that check if the user is authenticated in every request. Create a new file at src/routes/[email protected]
and add the following content.
import type { HeadersInit } from 'undici-types'
import type { RequestHandler } from '@builder.io/qwik-city'
import type { EnvGetter } from '@builder.io/qwik-city/middleware/request-handler'
type User = {
name: string
email: string
}
export const onRequest: RequestHandler = async ({ cookie, url, sharedMap, env }) => {
const session_name = env.get('PUBLIC_SESSION_NAME') ?? ''
const token = cookie.get('XSRF-TOKEN')
const session = cookie.get(session_name)
if (token && session) {
const laravelCookie = `XSRF-TOKEN=${encodeURIComponent(token.value)};${session_name}=${encodeURIComponent(session.value)}`
const headers: HeadersInit = {
'X-XSRF-TOKEN': token.value,
cookie: laravelCookie,
origin: url.origin,
}
const user = await getAuthenticatedUser(headers, env)
sharedMap.set('user', user)
}}
async function getAuthenticatedUser(headers: HeadersInit, env: EnvGetter) {
const endpoint = env.get('PUBLIC_API_ENDPOINT')
return await fetch(`${endpoint}/api/user`, {
method: 'GET',
// @ts-ignore
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...headers,
},
})
.then(async function (res) {
if (res.status === 200) {
return res.json()
} })
.then((data) => data as User)
}
What is going on here?
- We read
XSRF-TOKEN
value from the cookie passed from the browser. We also read the cookie value for the laravel session that matches thesession_name
. - If both cookie values are not
undefined
ornull
, the the user is like to be authenticated. - We make a
POST
request to${endpoint}/api/user
to get the authenticated user. In the request, we attachXSRF-TOKEN
asX-XSRF-TOKEN
header,origin
header and we attached laravel cookie. If we MUST include the three headers, else the request will return401
. - If the response form Laravel is successful, we store the user in the
sharedMap
. Data stored insharedMap
is accessible in the HTTP request. You can learn more about shared map from Qwik documentation
Securing dashboard
Let us add a dashboard route that is only accessible in to authenticated user.
npm run qwik new /dashboard
We then add a middleware that check is there is a user in sharedMap
. If not, we redirect the guest to login page.
import { component$ } from "@builder.io/qwik";
import { useUser } from "~/routes/layout";
import type { RequestHandler } from "@builder.io/qwik-city";
export const onRequest: RequestHandler = ({ redirect, sharedMap }) => {
if (!sharedMap.get("user")) {
throw redirect(302, "login");
}
};
export default component$(() => {
const user = useUser();
return (
<div class="min-h-screen w-full bg-gray-50">
<aside class="hidden min-h-dvh w-48 bg-white shadow md:fixed md:inset-y-0 md:left-0 md:flex">
Sidebar
</aside>
<div class="flex min-h-screen w-full items-center justify-center p-10 md:ml-48">
<div class="flex max-w-sm flex-col items-center space-y-2 bg-white p-6">
<img
src="https://i.pravatar.cc/300"
height="300"
width="300"
alt="sample user"
class="h-20 w-20 rounded-full"
/>
<div class="h1 text-2xl">Welcome</div>
<p class="text-lg">{user.value.name}</p>
<p class="text-lg">{user.value.email}</p>
</div>
</div>
</div>
);
});
The useUser
is a re-usable function placed in the src/routes/layout.tsx
that get the user from sharedMap
import { component$, Slot } from "@builder.io/qwik";
import { routeLoader$ } from "@builder.io/qwik-city";
import type { User } from "~/routes/plugin@auth";
export const useUser = routeLoader$(({ sharedMap }) => {
return sharedMap.get("user") as User;
});
export default component$(() => {
return <Slot />;
});
Conclusion
By now you should be able to implement other authentication requirements such as logout
and registration
. The source code for both the Laravel API and Qwik app is available on Github
Common errors
- You are getting
401
unauthenticated error even after successful login. Solution: Ensure your laravelSANCTUM_STATEFUL_DOMAINS
include you frontend e.gSANCTUM_STATEFUL_DOMAINS=localhost:5173