Building a Headless Authentication App with Ionic, Vue, and Clerk
This is an AI generated post of the video transcript with the code snippets integrated into the flow of the original video
Welcome back to the channel! This tutorial will guide you step-by-step on how to integrate Clerk, a full-stack authentication and user management system, into an Ionic Vue application. Clerk handles complex tasks like password management, login, logout, and email verification, freeing you from building these features from scratch. While Clerk is very popular in the React/Next.js world, we'll explore how to get it working seamlessly with Vue.js and Capacitor for packing solution on mobile device.
Our primary goal is to use Clerk as your authentication provider on a mobile app wrapped with Capacitor, specifically using Vue.
Demo of the Final Application
Let's start with a quick demo of the final application we're aiming to build. We're using Clerk in a "headless" manner, meaning we're not relying on Clerk's pre-built UI components. Instead, we've created a custom UI for our sign-in and sign-up screens. This approach gives you full control over the user experience.
Create Account Flow
When you navigate to the "Create Account" screen and enter your email, first name, last name, and password, Clerk handles the process. You'll see a CAPTCHA verification step, which Clerk manages automatically. After successful CAPTCHA, Clerk sends an email with a verification code. You'll then enter this code into the app to complete the sign-up process.
Once verified, you'll be logged in and redirected to the profile page.
Profile Page Features
The profile page displays user information like your email and user ID. We've also integrated calls to the Clerk Vue library to demonstrate how you can access additional user and session information, and even refresh the session.
Session Persistence
A crucial aspect of mobile applications is maintaining user state. If you close the app after logging in and then reopen it, the app should automatically re-authenticate you and take you back to the profile page. This demonstrates that Clerk successfully persists the session.
Sign Out and Re-Login
Finally, the app allows you to sign out and then log back in, confirming that the authentication flow works as expected.
This application serves as a template to help you understand how to integrate Clerk into your existing app or use it as a starting point for a new project.
Starting Point: The clerk-tutorial-start
Project
To make this project easier to follow, we're starting with a pre-built project called clerk-tutorial-start
. This is a basic Vue app with mock authentication, designed to save time on setting up basic screens and the Vue application structure.
Project Source Code - the starting point is on the main branch and the finished project is on the finished-code branch
This application is built with Ionic, utilizing Ionic components for the UI. However, you could easily swap these out for other UI frameworks like Tailwind or DaisyUI. Importantly, it already has iOS and Android integrated, allowing you to run it as a Capacitor app and deploy directly to your device.
For those who want to follow along with the code, I suggest you pause the video, check out the repo, and download the clerk-tutorial-start
project.
Setting Up Clerk
The next step is to get Clerk set up. Go to Clerk.com and log in. You'll need to set up a new project or application. Here are a few key configurations you'll need to make:
- Configure User Model: Since our application collects first name and last name during sign-up, you need to enable these fields in your Clerk application's user model. Go to "Configure User Model" and ensure "First Name" and "Last Name" are checked.
- Sign-Up Settings:
- Ensure "Sign up with email" is turned on.
- Enable "Verify at sign up."
- Select "Email code" for verification.
- You can also enable "Sign in with verification email code" if you plan to implement that flow.
- Important Note on Email Verification Links: While Clerk offers email verification links, it's generally recommended to use email codes for mobile devices. Verification links can be challenging to implement correctly on mobile due to external browser dependencies and a more complex flow. Clerk itself warns about poor conversion rates with links on mobile.
- API Keys: You'll need to retrieve your Publishable Key from the Clerk dashboard. This key will be used in your application's environment variables.
Once you've made these changes in your Clerk dashboard, you're ready to start modifying the project.
Modifying the Project: Adding Clerk Functionality
Step 1: Install Clerk and Set Environment Variable
First, ensure you have the Clerk Vue SDK installed:
npm install @clerk/vue
Next, create a .env
file in the root of your project (if you don't have one already) and set your Clerk Publishable Key:
VITE_CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
Replace pk_test_your_publishable_key_here
with the actual key from your Clerk dashboard.
Step 2: Integrate Clerk Plugin in main.ts
The main.ts
file is the entry point of our Vue application. Here, we'll import and initialize the Clerk plugin. This step is crucial as it makes Clerk's functionalities, like useClerk
and useUser
hooks, available throughout our application.
File: src/main.ts
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router/auth";
import { IonicVue } from "@ionic/vue";
// ... (Ionicons imports) ...
/* Theme variables */
import "./theme/variables.css";
// NEW: Import clerkPlugin
import { clerkPlugin } from "@clerk/vue";
const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
if (!PUBLISHABLE_KEY) {
throw new Error("Missing VITE_CLERK_PUBLISHABLE_KEY");
}
const app = createApp(App)
.use(IonicVue, {
mode: "ios",
})
.use(router)
// NEW: Initialize clerkPlugin
.use(clerkPlugin, {
publishableKey: PUBLISHABLE_KEY,
});
router.isReady().then(() => {
app.mount("#app");
});
Step 3: Implement Login/Logout with useAuth.ts
Composable
We'll create a useAuth
composable to centralize all our authentication logic. This is a powerful pattern in Vue that allows us to reuse this logic across our application.
Create a new file: src/composables/useAuth.ts
.
Understanding Clerk's Hooks
Inside useAuth.ts
, we'll leverage Clerk's Vue hooks:
-
useClerk()
: Provides access to the Clerk client instance, which contains methods for authentication actions likesignIn
,signUp
, andsignOut
. -
useUser()
: Provides reactive state about the current user, includingisSignedIn
(whether a user is logged in),isLoaded
(whether Clerk has finished initializing), and theuser
object itself.
File: src/composables/useAuth.ts
// NEW: Entire file is new
import { ref, computed } from "vue";
import { useClerk, useUser } from "@clerk/vue";
export function useAuth() {
const clerk = useClerk();
const { isSignedIn, user, isLoaded } = useUser();
const isLoading = ref(false);
const error = ref("");
// We will add our functions here
return {
isSignedIn: computed(() => isSignedIn.value),
user: computed(() => user.value),
isLoaded: computed(() => isLoaded.value),
isLoading: computed(() => isLoading.value),
error: computed(() => error.value),
};
}
The Sign-In Flow
The signIn
function handles user login. In Clerk's headless authentication, you call clerk.client.signIn.create()
with the user's credentials. Clerk processes this and returns a result
object with a status
.
- If
result.status
is"complete"
, the user is successfully authenticated. We then must callclerk.setActive({ session: result.createdSessionId })
to tell the Clerk SDK to activate this session in the client. This is a critical step for headless integration. - If the status is not "complete", or an error occurs, we handle it by setting an error message.
File: src/composables/useAuth.ts
(add signIn
function)
// ... (imports and setup) ...
const signIn = async (email, password) => {
isLoading.value = true;
error.value = "";
try {
const result = await clerk.value.client.signIn.create({
identifier: email,
password: password,
});
if (result.status === "complete") {
await clerk.value.setActive({ session: result.createdSessionId });
return true;
} else {
error.value = "Sign in failed";
return false;
}
} catch (err) {
error.value = err.message || "Sign in failed";
return false;
} finally {
isLoading.value = false;
}
};
// ... (return statement) ...
The Sign-Out Flow
The signOut
function is straightforward. Calling clerk.signOut()
instructs the Clerk SDK to terminate the current user session and clear any local authentication state.
File: src/composables/useAuth.ts
(add signOut
function)
// ... (imports and signIn function) ...
const signOut = async () => {
isLoading.value = true;
try {
await clerk.value.signOut();
return true;
} catch (err) {
console.error("Sign out error:", err);
return false;
} finally {
isLoading.value = false;
}
};
return {
// ... (state) ...
signIn,
signOut,
};
Updating Login and Profile Views
Now, we'll update src/views/LoginView.vue
and src/views/ProfileView.vue
to utilize our useAuth
composable for handling sign-in and sign-out actions.
File: src/views/LoginView.vue
<template>
<ion-page>
<ion-header>
<ion-toolbar>
<ion-title>Sign In</ion-title>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true">
<div class="container">
<!-- Login Form -->
<ion-card>
<ion-card-header>
<ion-card-title>Welcome Back</ion-card-title>
<ion-card-subtitle>Sign in to your account</ion-card-subtitle>
</ion-card-header>
<ion-card-content>
<ion-item>
<ion-label position="stacked">Email</ion-label>
<ion-input
v-model="email"
type="email"
placeholder="Enter your email"
:disabled="isLoading"
></ion-input>
</ion-item>
<ion-item>
<ion-label position="stacked">Password</ion-label>
<ion-input
v-model="password"
type="password"
placeholder="Enter your password"
:disabled="isLoading"
></ion-input>
</ion-item>
<div class="ion-padding">
<ion-button
@click="handleSignIn"
expand="block"
:disabled="isLoading || !email || !password"
>
<ion-spinner v-if="isLoading" name="crescent"></ion-spinner>
<ion-icon v-else name="log-in-outline" slot="start"></ion-icon>
Sign In
</ion-button>
<ion-button
@click="navigateToSignUp"
expand="block"
fill="outline"
:disabled="isLoading"
>
<ion-icon name="person-add-outline" slot="start"></ion-icon>
Don't have an account? Sign Up
</ion-button>
</div>
<!-- Error Display -->
<ion-item v-if="error" color="danger">
<ion-icon name="warning" slot="start"></ion-icon>
<ion-label>{{ error }}</ion-label>
</ion-item>
</ion-card-content>
</ion-card>
</div>
</ion-content>
</ion-page>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import {
IonPage,
IonHeader,
IonToolbar,
IonTitle,
IonContent,
IonCard,
IonCardHeader,
IonCardTitle,
IonCardSubtitle,
IonCardContent,
IonItem,
IonLabel,
IonInput,
IonButton,
IonIcon,
IonSpinner,
} from "@ionic/vue";
// NEW: Import useAuth
import { useAuth } from "../composables/useAuth";
const router = useRouter();
// NEW: Use auth composable
const { signIn, error: authError, isLoading } = useAuth();
// Form state
const email = ref("");
const password = ref("");
const error = ref("");
/**
* Handle sign in form submission
*/
const handleSignIn = async () => {
if (!email.value || !password.value) {
error.value = "Please fill in all fields";
return;
}
try {
error.value = "";
// NEW: Call signIn from useAuth
const result = await signIn(email.value, password.value);
if (result) {
// Clear form
email.value = "";
password.value = "";
// Navigate to profile
router.push("/profile");
} else {
error.value = authError.value || "Invalid email or password";
}
} catch (err) {
error.value = err instanceof Error ? err.message : "Sign in failed";
}
};
/**
* Navigate to sign up view
*/
const navigateToSignUp = () => {
router.push("/signup");
};
</script>
<style scoped>
.container {
padding: 16px;
max-width: 400px;
margin: 0 auto;
}
ion-item {
--padding-start: 0;
--inner-padding-end: 0;
}
ion-button {
margin: 8px 0;
}
</style>
File: src/views/ProfileView.vue
<template>
<ion-page>
<ion-header>
<ion-toolbar>
<ion-title>Profile</ion-title>
<ion-buttons slot="end">
<ion-button @click="handleSignOut" color="danger">
<ion-icon name="log-out-outline" slot="start"></ion-icon>
Sign Out
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true">
<div class="container">
<!-- User Profile Card -->
<ion-card>
<ion-card-content>
<div class="profile-container">
<!-- Avatar at the top -->
<ion-avatar class="profile-avatar">
<img
v-if="user?.imageUrl"
:src="user.imageUrl"
:alt="
user ? `${user.firstName} ${user.lastName}` : 'User Avatar'
"
/>
<div v-else class="avatar-placeholder">
{{ (user?.firstName || "U").charAt(0).toUpperCase() }}
</div>
</ion-avatar>
<!-- User details below avatar -->
<div class="profile-details">
<h2 class="welcome-text">
Welcome, {{ user?.firstName || "User" }}!
</h2>
<p class="user-email">
{{
user?.emailAddresses[0]?.emailAddress ||
"Email not provided"
}}
</p>
<div class="user-info">
<p>
<strong>Name:</strong>
{{ user?.firstName }} {{ user?.lastName }}
</p>
<p>
<strong>Email:</strong>
{{
user?.emailAddresses[0]?.emailAddress || "Not provided"
}}
</p>
<p>
<strong>User ID:</strong> {{ user?.id || "Not available" }}
</p>
<p>
<strong>Member since:</strong>
{{ formatDate(new Date()) }}
</p>
</div>
</div>
</div>
</ion-card-content>
</ion-card>
<!-- Auth Actions Component -->
<AuthActions />
</div>
</ion-content>
</ion-page>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import {
IonPage,
IonHeader,
IonToolbar,
IonTitle,
IonContent,
IonCard,
IonCardContent,
IonButton,
IonButtons,
IonIcon,
IonAvatar,
} from "@ionic/vue";
// NEW: Import useAuth
import { useAuth } from "../composables/useAuth";
import AuthActions from "../components/AuthActions.vue";
const router = useRouter();
// NEW: Use auth composable
const { user, signOut } = useAuth();
/**
* Handle user sign out
*/
const handleSignOut = async () => {
// NEW: Call signOut from useAuth
await signOut();
router.push("/login");
};
/**
* Format date for display
*/
const formatDate = (date: Date | null | undefined) => {
if (!date) return "Not available";
return new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
</script>
<style scoped>
.container {
padding: 16px;
}
.profile-container {
text-align: center;
padding: 20px;
}
.profile-avatar {
width: 120px;
height: 120px;
margin: 0 auto 20px;
--border-radius: 50%;
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(
135deg,
var(--ion-color-primary),
var(--ion-color-secondary)
);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
font-weight: bold;
color: white;
}
.profile-details {
text-align: center;
}
.welcome-text {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
color: var(--ion-color-dark);
}
.user-email {
margin: 0 0 20px 0;
font-size: 16px;
color: var(--ion-color-medium);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.user-info {
text-align: left;
background: var(--ion-color-light);
border-radius: 12px;
padding: 16px;
margin-top: 20px;
}
.user-info p {
margin: 12px 0;
font-size: 14px;
line-height: 1.4;
}
.user-info strong {
color: var(--ion-color-primary);
font-weight: 600;
}
</style>
Checkpoint: Testing Login/Logout
At this point, you should be able to run the app and test the login and logout functionality. Run npm run dev
to start the development server. You can create a user directly in your Clerk dashboard to test the login.
Step 4: Create Accounts & Handle Verification
Now let's add the signUp
flow. This is a multi-step process involving:
- Creating the user.
- If required, prompting the user to verify their email address.
- Completing the verification to grant the user a session.
The Sign-Up Logic in useAuth.ts
In our useAuth.ts
composable, the signUp
function will handle step 1. When we call clerk.client.signUp.create()
, Clerk attempts to create the user.
- If
result.status
is"complete"
, the user is created and immediately authenticated. - If
result.status
is"missing_requirements"
, it means the user needs to verify their email. Clerk then sends an email with a verification code, and we callresult.prepareEmailAddressVerification()
to prepare for the next step. This is Clerk telling our frontend, "The user is created, but now you need to prove they own this email address before I give them a full session."
The Verification Logic in useAuth.ts
The handleVerification
function will handle step 3. It takes the code the user provides (from their email) and sends it to Clerk's signUpAttempt.attemptEmailAddressVerification()
endpoint. If the verification is successful (verification.status === "complete"
), the user is fully verified, and we then call clerk.setActive()
to establish their session.
Let's add these functions to our composable:
File: src/composables/useAuth.ts
(add signUp
and handleVerification
functions)
// ... (imports and other functions) ...
const signUp = async (email, password, firstName, lastName) => {
isLoading.value = true;
error.value = "";
try {
const result = await clerk.value.client.signUp.create({
emailAddress: email,
password: password,
firstName: firstName,
lastName: lastName,
});
if (result.status === "complete") {
await clerk.value.setActive({ session: result.createdSessionId });
return true;
} else if (result.status === "missing_requirements") {
// Clerk requires email verification
await result.prepareEmailAddressVerification({
strategy: "email_code",
});
error.value = "Email verification required";
return false;
} else {
error.value = "Sign up failed";
return false;
}
} catch (err) {
error.value = err.message || "Sign up failed";
return false;
} finally {
isLoading.value = false;
}
};
const handleVerification = async (code) => {
isLoading.value = true;
error.value = "";
try {
const signUpAttempt = clerk.value.client.signUp;
if (!signUpAttempt) {
throw new Error("No sign-up attempt found");
}
const verification = await signUpAttempt.attemptEmailAddressVerification({
code: code,
});
if (verification.status === "complete") {
// Verification successful, activate the session
await clerk.value.setActive({ session: verification.createdSessionId });
return true;
} else {
error.value = "Verification failed";
return false;
}
} catch (err) {
error.value = err.message || "Verification failed";
return false;
}
};
return {
// ... (state and other functions) ...
signUp,
handleVerification,
};
Updating the Sign-Up View (SignUpView.vue
)
Now we need to update our SignUpView.vue
to handle this multi-step flow. This involves both template (UI) and script (logic) changes.
Template Changes for Verification
First, we'll add a new section to our template that will only be shown when the user needs to verify their email. We'll use a v-if="needsVerification"
directive to control its visibility. This section will contain an input field for the verification code and a button to submit it.
File: src/views/SignUpView.vue
(Template)
<template>
<ion-page>
<ion-header>
<ion-toolbar>
<ion-title>Create Account</ion-title>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true">
<!-- NEW: Add this div for CAPTCHA, Clerk will render into it -->
<div id="clerk-captcha"></div>
<div class="container">
<!-- Sign Up Form -->
<ion-card>
<ion-card-header>
<ion-card-title>Create Your Account</ion-card-title>
<ion-card-subtitle>Join us today</ion-card-subtitle>
</ion-card-header>
<ion-card-content>
<ion-item>
<ion-label position="stacked">Email</ion-label>
<ion-input
v-model="email"
type="email"
placeholder="Enter your email"
:disabled="isLoading"
></ion-input>
</ion-item>
<ion-item>
<ion-label position="stacked">First Name</ion-label>
<ion-input
v-model="firstName"
type="text"
placeholder="Enter your first name"
:disabled="isLoading"
></ion-input>
</ion-item>
<ion-item>
<ion-label position="stacked">Last Name</ion-label>
<ion-input
v-model="lastName"
type="text"
placeholder="Enter your last name"
:disabled="isLoading"
></ion-input>
</ion-item>
<ion-item>
<ion-label position="stacked">Password</ion-label>
<ion-input
v-model="password"
type="password"
placeholder="Enter your password"
:disabled="isLoading"
></ion-input>
</ion-item>
<ion-item>
<ion-label position="stacked">Confirm Password</ion-label>
<ion-input
v-model="confirmPassword"
type="password"
placeholder="Confirm your password"
:disabled="isLoading"
></ion-input>
</ion-item>
<div class="ion-padding">
<ion-button
@click="handleSignUp"
expand="block"
:disabled="
isLoading || !email || !password || !firstName || !lastName
"
>
<ion-spinner v-if="isLoading" name="crescent"></ion-spinner>
<ion-icon
v-else
name="person-add-outline"
slot="start"
></ion-icon>
Create Account
</ion-button>
<ion-button
@click="navigateToLogin"
expand="block"
fill="outline"
:disabled="isLoading"
>
<ion-icon name="log-in-outline" slot="start"></ion-icon>
Already have an account? Sign In
</ion-button>
</div>
<!-- NEW: Verification code input section -->
<div v-if="needsVerification" class="ion-padding">
<ion-item>
<ion-label position="stacked">Verification Code</ion-label>
<ion-input
v-model="verificationCode"
type="text"
placeholder="Enter code from email"
:disabled="isLoading"
></ion-input>
</ion-item>
<ion-button
@click="handleVerify"
expand="block"
:disabled="isLoading || !verificationCode"
>
<ion-spinner v-if="isLoading" name="crescent"></ion-spinner>
<ion-icon
v-else
name="checkmark-outline"
slot="start"
></ion-icon>
Verify Account
</ion-button>
</div>
<!-- Error Display -->
<ion-item v-if="localError" color="danger">
<ion-icon name="warning" slot="start"></ion-icon>
<ion-label>{{ localError }}</ion-label>
</ion-item>
</ion-card-content>
</ion-card>
</div>
</ion-content>
</ion-page>
</template>
Script Changes for Verification Flow
Now, let's update the script. When our handleSignUp
function calls the signUp
method from our composable, if it returns false
and the error message indicates "verification required", we will set needsVerification
to true
. This will make our new UI section appear, prompting the user for the code they received in their email. The handleVerify
function will then take this code and complete the verification.
File: src/views/SignUpView.vue
(Script)
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import {
IonPage,
IonHeader,
IonToolbar,
IonTitle,
IonContent,
IonCard,
IonCardHeader,
IonCardTitle,
IonCardSubtitle,
IonCardContent,
IonItem,
IonLabel,
IonInput,
IonButton,
IonIcon,
IonSpinner,
} from "@ionic/vue";
// NEW: Import useAuth
import { useAuth } from "../composables/useAuth";
const router = useRouter();
// NEW: Use auth composable
const { signUp, handleVerification, error: authError, isLoading } = useAuth();
// Form state
const email = ref("");
const password = ref("");
const confirmPassword = ref("");
const firstName = ref("");
const lastName = ref("");
const localError = ref("");
// NEW: Add state for verification UI
const needsVerification = ref(false);
const verificationCode = ref("");
/**
* Handles sign up form submission.
*/
const handleSignUp = async () => {
if (!email.value || !password.value || !firstName.value || !lastName.value) {
localError.value = "Please fill in all fields";
return;
}
if (password.value !== confirmPassword.value) {
localError.value = "Passwords do not match";
return;
}
try {
localError.value = "";
// NEW: Call signUp from useAuth
const result = await signUp(email.value, password.value, firstName.value, lastName.value);
if (result) {
clearForm();
router.push("/profile");
} else {
localError.value = authError.value || "Sign up failed";
// NEW: If verification is required, show the verification UI
if (localError.value.includes("verification")) {
needsVerification.value = true;
}
}
} catch (err) {
localError.value = err instanceof Error ? err.message : "Sign up failed";
}
};
// NEW: Add handleVerify method for email code submission
/**
* Handles verification code submission.
*/
const handleVerify = async () => {
if (!verificationCode.value) {
localError.value = "Please enter the verification code";
return;
}
try {
localError.value = "";
const result = await handleVerification(verificationCode.value);
if (result) {
needsVerification.value = false;
clearForm();
router.push("/profile");
} else {
localError.value = authError.value || "Verification failed";
}
} catch (err) {
localError.value = err instanceof Error ? err.message : "Verification failed";
}
};
// NEW: Add clearForm method to reset all form fields and state
/**
* Clears all form fields and resets local state.
*/
const clearForm = () => {
email.value = "";
password.value = "";
confirmPassword.value = "";
firstName.value = "";
lastName.value = "";
localError.value = "";
verificationCode.value = "";
needsVerification.value = false;
};
/**
* Navigates to the login view.
*/
const navigateToLogin = () => {
router.push("/login");
};
</script>
Checkpoint: Testing Account Creation
Now you can test the full sign-up flow, including email verification. Try creating a new account, check your email for the code, and then enter it into the app.
Step 5: Handle Auth State with Suspense
and Watchers
To ensure our app is reactive to authentication state changes (e.g., when a user logs in or out in another tab) and to provide a smooth loading experience, we will use Vue's Suspense
feature and a watcher. This setup will show a loading spinner while Clerk is initializing and then automatically redirect the user based on their authentication status.
First, create a LoadingSpinner.vue
component in src/components
. This component will be displayed while Clerk is loading.
File: src/components/LoadingSpinner.vue
<!-- NEW: Entire file is new -->
<template>
<ion-page>
<ion-content class="loading-content">
<div class="loading-container">
<ion-spinner name="crescent" color="primary"></ion-spinner>
<h2>Loading...</h2>
<p>Initializing authentication</p>
</div>
</ion-content>
</ion-page>
</template>
<script setup lang="ts">
import { IonPage, IonContent, IonSpinner } from "@ionic/vue";
</script>
<style scoped>
.loading-content { --background: var(--ion-color-light); }
.loading-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; }
</style>
Next, create an AppContent.vue
component in src/components
. This component will wait for Clerk to be fully loaded (isLoaded
) and then watches the isSignedIn
state and the current route to perform redirects.
File: src/components/AppContent.vue
<!-- NEW: Entire file is new -->
<template>
<ion-router-outlet />
</template>
<script setup lang="ts">
import { watch } from "vue";
import { useRouter, useRoute } from "vue-router";
import { IonRouterOutlet } from "@ionic/vue";
import { useAuth } from "../composables/useAuth";
const router = useRouter();
const route = useRoute();
const { isSignedIn, isLoaded } = useAuth();
// Wait for Clerk to be fully loaded before rendering the main content
await new Promise((resolve) => {
const unwatch = watch(isLoaded, (loaded) => {
if (loaded) { unwatch(); resolve(); }
}, { immediate: true });
});
// Watch for changes in authentication state or route to handle redirects
watch([isSignedIn, route], ([signedIn, currentRoute]) => {
const protectedRoutes = ["/profile"]; // Routes that require authentication
const guestRoutes = ["/login", "/signup"]; // Routes that should redirect if user is signed in
// If trying to access a protected route and not signed in, redirect to login
if (protectedRoutes.includes(currentRoute.path) && !signedIn) {
router.replace("/login");
return;
}
// If trying to access a guest route and already signed in, redirect to profile
if (guestRoutes.includes(currentRoute.path) && signedIn) {
router.replace("/profile");
return;
}
}, { immediate: true });
</script>
Finally, update App.vue
to use Vue's Suspense
feature. This allows us to display the LoadingSpinner
while AppContent
(which is asynchronous due to waiting for Clerk to load) is being prepared.
File: src/App.vue
<template>
<ion-app>
<!-- NEW: Use Suspense to handle loading state -->
<Suspense>
<!-- Main Content -->
<template #default>
<AppContent />
</template>
<!-- Fallback Content -->
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</ion-app>
</template>
<script setup lang="ts">
import { IonApp } from "@ionic/vue";
import { defineAsyncComponent } from "vue";
import LoadingSpinner from "./components/LoadingSpinner.vue";
// Define the main app content as an async component
const AppContent = defineAsyncComponent(() => import("@/components/AppContent.vue"));
</script>
Checkpoint: Testing the Watcher
Your app will now show a loading spinner on startup and handle redirects automatically based on the authentication state. Test closing and reopening the app after logging in to see the session persistence in action.
Step 6: Capacitor Integration
This project is already set up with Capacitor, which allows you to build and deploy your web app to mobile devices.
How to Build
To create a production build of your web app, run:
npm run build
This command compiles your Vue application and places the optimized output in the dist
folder.
How to Deploy to a Device
To deploy your app to a connected Android or iOS device, you can use the following Capacitor commands:
Android:
npx cap sync android
npx cap run android
iOS:
npx cap sync ios
npx cap run ios
These commands will synchronize your web build with the native project and then launch the app on your connected device or emulator.
Quirks and Platform-Specific Changes
When building for mobile, you sometimes need to make platform-specific configuration changes to ensure everything works correctly.
Android (AndroidManifest.xml
)
For Android, you need to allow cleartext traffic in your android/app/src/main/AndroidManifest.xml
file. This is often necessary for development environments or when communicating with certain APIs that might not enforce HTTPS.
File: android/app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"> <!-- NEW: Allow cleartext traffic -->
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
iOS (Info.plist
)
For iOS, you need to add the WKAppBoundDomains
key to your ios/App/App/Info.plist
file. This is crucial for allowing the web view (where your Ionic app runs) to communicate with your Clerk instance, especially for authentication redirects. You will also need to allow arbitrary loads for the Clerk domains under NSAppTransportSecurity
.
File: ios/App/App/Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>clerk-ionic-app-template</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<!-- NEW: Add WKAppBoundDomains for Clerk -->
<key>WKAppBoundDomains</key>
<array>
<string>your-clerk-instance.clerk.accounts.dev</string>
</array>
<!-- NEW: Add NSAppTransportSecurity to allow arbitrary loads for Clerk domains -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
Note: Replace your-clerk-instance.clerk.accounts.dev
with your actual Clerk instance URL.
Conclusion
Congratulations! You have successfully added headless authentication to your Ionic Vue application with Clerk. You now have a secure and robust authentication solution that works seamlessly on the web and mobile devices.
We covered the basic functionality, including sign-in, sign-up with email verification, and session management. We also touched upon debugging common issues and configuring platform-specific settings for Capacitor. While we didn't cover features like "forgot password" or "reset password," these can be added to make your solution even more complete.
Hopefully, you found this tutorial helpful. If you did, please make sure you like and subscribe, and I'll see you next time!
Top comments (0)