Protecting Routes and Content in Vue
Vue protected routes authentication Vue Router session management route guardsThis tutorial part demonstrates how to implement robust route protection in a Vue application using Nhost authentication. You’ll build a complete authentication system with a protected /profile page that includes cross-tab session synchronization and automatic redirects. In addition, we will see how to show conditional navigation and content based on authentication status.
Full-Stack Vue Development with Nhost
Section titled “Full-Stack Vue Development with Nhost”Prerequisites
Section titled “Prerequisites”- An Nhost project set up
- Node.js 20+ installed
- Basic knowledge of Vue and Vue Router
Step-by-Step Guide
Section titled “Step-by-Step Guide”Create a New Vue App
Section titled “Create a New Vue App”We’ll start by creating a fresh Vue application using Vite with TypeScript support. Vite provides fast development server and optimized builds for modern Vue applications.
npm create vue@latest nhost-vue-tutorial -- --typescript --routercd nhost-vue-tutorialnpm installInstall Required Dependencies
Section titled “Install Required Dependencies”Install the Nhost JavaScript SDK. The Nhost SDK handles authentication, while Vue Router (already included) enables protected route navigation.
npm install @nhost/nhost-jsEnvironment Configuration
Section titled “Environment Configuration”Configure your Nhost project connection by creating environment variables. This allows the app to connect to your specific Nhost backend.
Create a .env file in your project root:
VITE_NHOST_REGION=<region>VITE_NHOST_SUBDOMAIN=<subdomain>Create the Nhost Auth Composable
Section titled “Create the Nhost Auth Composable”Build the core authentication composable that manages user sessions across your Vue application. This composable provides reactive authentication state and handles cross-tab synchronization.
import { createClient } from "@nhost/nhost-js";import type { Session } from "@nhost/nhost-js/auth";import { computed, reactive } from "vue";
// Global reactive stateconst authState = reactive({ user: null as Session["user"] | null, session: null as Session | null, isLoading: true,});
// Create the nhost clientconst nhost = createClient({ region: (import.meta.env["VITE_NHOST_REGION"] as string) || "local", subdomain: (import.meta.env["VITE_NHOST_SUBDOMAIN"] as string) || "local",});
// Subscription cleanup functionlet unsubscribe: (() => void) | null = null;let lastRefreshTokenIdRef: string | null = null;let isInitialized = false;
/** * Handles session reload when refresh token changes. * This detects when the session has been updated from other tabs. * * @param currentRefreshTokenId - The current refresh token ID to compare against stored value */const reloadSession = (currentRefreshTokenId: string | null) => { if (currentRefreshTokenId !== lastRefreshTokenIdRef) { lastRefreshTokenIdRef = currentRefreshTokenId;
// Update local authentication state to match current session const currentSession = nhost.getUserSession(); authState.user = currentSession?.user || null; authState.session = currentSession; }};
// Initialize auth stateconst initializeAuth = () => { if (isInitialized) return;
authState.isLoading = true;
// Set initial values const currentSession = nhost.getUserSession(); authState.user = currentSession?.user || null; authState.session = currentSession; lastRefreshTokenIdRef = currentSession?.refreshTokenId ?? null; authState.isLoading = false;
// Subscribe to session changes from other browser tabs // This enables real-time synchronization when user signs in/out in another tab unsubscribe = nhost.sessionStorage.onChange((currentSession) => { reloadSession(currentSession?.refreshTokenId ?? null); });
// Handle session changes from page focus events (for additional session consistency) const checkSessionOnFocus = () => { reloadSession(nhost.getUserSession()?.refreshTokenId ?? null); };
// Monitor page visibility changes (tab switching, window minimizing) document.addEventListener("visibilitychange", () => { if (!document.hidden) { checkSessionOnFocus(); } });
// Monitor window focus events (clicking back into the browser window) window.addEventListener("focus", checkSessionOnFocus);
isInitialized = true;};
// Cleanup functionconst cleanup = () => { if (unsubscribe) { unsubscribe(); unsubscribe = null; } isInitialized = false;};
/** * Vue composable for authentication state and operations. * * Provides reactive access to current user session, authentication state, and Nhost client. * Handles cross-tab session synchronization and automatic state updates. * * @returns Object containing reactive authentication state and Nhost client * * @example * ```vue * <script setup lang="ts"> * import { useAuth } from './lib/nhost/auth'; * * const { user, isAuthenticated, nhost } = useAuth(); * </script> * * <template> * <div v-if="!isAuthenticated">Please sign in</div> * <div v-else>Welcome, {{ user?.displayName }}!</div> * </template> * ``` */export function useAuth() { // Initialize auth if not already done if (!isInitialized && typeof window !== "undefined") { initializeAuth(); }
return { user: computed(() => authState.user), session: computed(() => authState.session), isLoading: computed(() => authState.isLoading), isAuthenticated: computed(() => !!authState.session), nhost, };}
// Initialize auth immediately (for SSR compatibility)if (typeof window !== "undefined") { initializeAuth();}
// Cleanup on window unloadif (typeof window !== "undefined") { window.addEventListener("beforeunload", cleanup);}Create the Profile Page
Section titled “Create the Profile Page”Create a page that displays user information. Note that the page itself doesn’t need any special authentication logic - the route protection is handled entirely by the Vue Router navigation guard we set up in the previous step.
<template> <div v-if="isLoading" class="loading-container"> <div class="loading-content"> <div class="spinner"></div> <span class="loading-text">Loading...</span> </div> </div>
<div v-else class="container"> <header class="page-header"> <h1 class="page-title">Your Profile</h1> </header>
<div class="form-card"> <h3 class="form-title">User Information</h3> <div class="form-fields"> <div class="field-group"> <strong>Display Name:</strong> {{ user?.displayName || "Not set" }} </div> <div class="field-group"> <strong>Email:</strong> {{ user?.email || "Not available" }} </div> <div class="field-group"> <strong>User ID:</strong> {{ user?.id || "Not available" }} </div> <div class="field-group"> <strong>Roles:</strong> {{ user?.roles?.join(", ") || "None" }} </div> <div class="field-group"> <strong>Email Verified:</strong> <span :class="user?.emailVerified ? 'email-verified' : 'email-unverified'"> {{ user?.emailVerified ? "✓ Yes" : "✗ No" }} </span> </div> </div> </div>
<div class="form-card"> <h3 class="form-title">Session Information</h3> <div class="description"> <pre class="session-display">{{ JSON.stringify(session, null, 2) }}</pre> </div> </div> </div></template>
<script setup lang="ts">import { useAuth } from "../lib/nhost/auth";
const { user, session, isLoading } = useAuth();</script>Create a Simple Home Page
Section titled “Create a Simple Home Page”Build a public homepage that adapts its content based on authentication status. This shows users different options depending on whether they’re signed in.
<template> <div class="container"> <header class="page-header"> <h1 class="page-title">Welcome to Nhost Vue Demo</h1> </header>
<div v-if="isAuthenticated"> <p>Hello, {{ user?.displayName || user?.email }}!</p> </div> <div v-else> <p>You are not signed in.</p> </div> </div></template>
<script setup lang="ts">import { useAuth } from "../lib/nhost/auth";
const { isAuthenticated, user } = useAuth();</script>Create the Navigation Component
Section titled “Create the Navigation Component”Create a reusable navigation component that provides consistent navigation across all pages. This component adapts its links based on authentication status - showing different options for signed-in and signed-out users.
<template> <nav class="navigation"> <div class="nav-container"> <RouterLink to="/" class="nav-logo"> Nhost Vue Demo </RouterLink>
<div class="nav-links"> <RouterLink to="/" class="nav-link"> Home </RouterLink>
<template v-if="isAuthenticated"> <RouterLink to="/profile" class="nav-link"> Profile </RouterLink> </template> <template v-else> Placeholder for signin/signup links </template> </div> </div> </nav></template>
<script setup lang="ts">import { RouterLink } from "vue-router";import { useAuth } from "../lib/nhost/auth";
const { isAuthenticated } = useAuth();</script>Configure Protected Routes with Vue Router
Section titled “Configure Protected Routes with Vue Router”Set up Vue Router to handle route protection using navigation guards. This approach provides centralized route protection and automatic redirects for unauthenticated users.
import { createRouter, createWebHistory } from "vue-router";import { useAuth } from "../lib/nhost/auth";import HomeView from "../views/HomeView.vue";import ProfileView from "../views/ProfileView.vue";
const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: "/", name: "home", component: HomeView, }, { path: "/profile", name: "profile", component: ProfileView, meta: { requiresAuth: true }, }, { path: "/:pathMatch(.*)*", redirect: "/", }, ],});
// Navigation guard for protected routesrouter.beforeEach((to) => { if (to.meta["requiresAuth"]) { const { isAuthenticated, isLoading } = useAuth();
// Show loading state while authentication is being checked if (isLoading.value) { // You can return a loading component path or handle loading in the component return true; // Allow navigation, handle loading in component }
if (!isAuthenticated.value) { return "/"; // Redirect to home page } } return true;});
export default router;Update the Main App Component
Section titled “Update the Main App Component”Configure the application’s main component to include the navigation bar and router view. Since we’ve configured route protection in the router itself, the App component mainly needs to provide the layout structure.
<template> <div id="app"> <Navigation /> <div class="app-content"> <RouterView /> </div> </div></template>
<script setup lang="ts">import { RouterView } from "vue-router";import Navigation from "./components/Navigation.vue";</script>Add Application Styles
Section titled “Add Application Styles”Replace the contents of the file src/assets/main.css with the following styles to provide a clean and modern look for the application. This file will be used across the rest of series.
:root { font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; font-weight: 400;
color-scheme: dark; color: rgba(255, 255, 255, 0.87); background-color: #242424;
font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;}
body { margin: 0; display: flex; place-items: center; min-width: 320px; min-height: 100vh;}
#app { width: 100%; min-height: 100vh; display: block; margin: 0; padding: 0;}
a { font-weight: 500; color: #646cff; text-decoration: inherit;}
a:hover { color: #535bf2;}
h1 { font-size: 3.2em; line-height: 1.1;}
button { border-radius: 8px; border: 1px solid transparent; padding: 0.6em 1.2em; font-size: 1em; font-weight: 500; font-family: inherit; background-color: #1a1a1a; cursor: pointer; transition: border-color 0.25s;}
button:hover { border-color: #646cff;}
button:focus,button:focus-visible { outline: 4px auto -webkit-focus-ring-color;}
input,textarea { width: 100%; padding: 0.875rem 1rem; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; font-size: 0.875rem; transition: all 0.2s ease; background: rgba(255, 255, 255, 0.05); color: white; box-sizing: border-box; font-family: inherit;}
input:focus,textarea:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); background: rgba(255, 255, 255, 0.08);}
input::placeholder,textarea::placeholder { color: rgba(255, 255, 255, 0.5);}
textarea { resize: vertical; min-height: 4rem;}
label { display: block; margin: 0 0 0.5rem 0; font-weight: 500; color: rgba(255, 255, 255, 0.9); font-size: 0.875rem;}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); }}
/* Global Layout */.app-content { padding: 0 2rem 2rem; max-width: 800px; margin: 0 auto;}
.page-center { text-align: center; padding: 2rem;}
.page-header { margin-bottom: 2rem;}
.page-title { font-weight: 700; margin: 0; display: flex; align-items: center; gap: 1rem;}
.margin-bottom { margin-bottom: 1rem;}
.margin-top { margin-top: 1rem;}
.container { width: 800px; max-width: calc(100vw - 4rem); min-width: 320px; margin: 0 auto; padding: 2rem; box-sizing: border-box; position: relative;}
/* Status Messages */.success-message { padding: 1rem; background-color: #d4edda; color: #155724; border-radius: 8px; margin-bottom: 1rem;}
.error-message { background: rgba(239, 68, 68, 0.1); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.3); border-radius: 12px; padding: 1rem 1.5rem; margin: 1rem 0;}
.help-text { color: #666;}
.verification-status { color: #28a745; font-size: 1.2rem; font-weight: bold; margin-bottom: 1rem;}
.verification-status.error { color: #dc3545; font-size: 1.1rem;}
/* Email Verification Status */.email-verified { color: #10b981; font-weight: bold; margin-left: 0.5rem;}
.email-unverified { color: #ef4444; font-weight: bold; margin-left: 0.5rem;}
/* Debug Info */.debug-panel { margin-bottom: 1rem; padding: 1rem; background-color: #f8f9fa; border-radius: 8px; text-align: left; max-height: 200px; overflow: auto;}
.debug-title { font-weight: bold; margin-bottom: 0.5rem;}
.debug-item { margin-bottom: 0.25rem;}
.debug-key { font-family: monospace; color: #007bff;}
.debug-value { font-family: monospace;}
/* Session Display */.session-display { font-size: 0.75rem; overflow: auto; margin: 0; line-height: 1.4; white-space: pre-wrap; word-break: break-word;}
/* Loading Spinner */.spinner-verify { width: 32px; height: 32px; border: 3px solid #f3f3f3; border-top: 3px solid #007bff; border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto;}
/* Navigation */.navigation { background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(10px); border-bottom: 1px solid rgba(255, 255, 255, 0.1); position: sticky; top: 0; z-index: 100; margin-bottom: 2rem;}
.nav-container { max-width: 800px; margin: 0 auto; padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center;}
.nav-logo { font-size: 1.25rem; font-weight: 700; color: white; text-decoration: none; background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;}
.nav-logo:hover { opacity: 0.8;}
.nav-links { display: flex; align-items: center; gap: 1.5rem;}
.nav-link { color: rgba(255, 255, 255, 0.8); text-decoration: none; font-weight: 500; font-size: 0.875rem; padding: 0.5rem 0.75rem; border-radius: 6px; transition: all 0.2s ease; border: none; background: none; cursor: pointer; font-family: inherit;}
.nav-link:hover { color: white; background: rgba(255, 255, 255, 0.1);}
.nav-button { color: #ef4444;}
.nav-button:hover { background: rgba(239, 68, 68, 0.2);}
/* Buttons */.btn { padding: 0.75rem 1.5rem; border: none; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.2s ease; font-size: 0.875rem; display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; min-width: 120px;}
.btn-primary { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); color: white;}
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);}
.btn-secondary { background: rgba(255, 255, 255, 0.1); color: white; border: 1px solid rgba(255, 255, 255, 0.2);}
.btn-secondary:hover { background: rgba(255, 255, 255, 0.2);}
.btn-cancel { background: rgba(239, 68, 68, 0.1); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.3);}
.btn-cancel:hover { background: rgba(239, 68, 68, 0.2);}
/* Loading State */.loading-container { display: flex; justify-content: center; align-items: center; padding: 4rem 2rem;}
.loading-content { display: flex; align-items: center; gap: 1rem;}
.spinner { width: 2rem; height: 2rem; border: 3px solid rgba(59, 130, 246, 0.3); border-top: 3px solid #3b82f6; border-radius: 50%; animation: spin 1s linear infinite;}
.loading-text { color: rgba(255, 255, 255, 0.7); font-size: 0.875rem;}
/* Empty State */.empty-state { text-align: center; padding: 4rem 2rem; background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 16px;}
.empty-icon { width: 4rem; height: 4rem; color: rgba(255, 255, 255, 0.4); margin: 0 auto 1rem;}
.empty-title { font-size: 1.25rem; font-weight: 600; color: white; margin: 0 0 0.5rem 0;}
.empty-description { color: rgba(255, 255, 255, 0.6); margin: 0;}
/* Forms */.form-card { background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 16px; padding: 2rem; margin-bottom: 2rem; width: 100%; box-sizing: border-box;}
.form-title { font-size: 1.5rem; font-weight: 600; color: white; margin: 0 0 1.5rem 0;}
.form-fields { display: flex; flex-direction: column; gap: 1.5rem;}
.field-group { display: flex; flex-direction: column;}
.form-actions { display: flex; gap: 1rem; margin-top: 1rem;}
/* Auth Pages */.auth-form { max-width: 400px;}
.auth-form-field { margin-bottom: 1rem;}
.auth-input { width: 100%; padding: 0.5rem; margin-top: 0.25rem;}
.auth-error { color: red; margin-bottom: 1rem; padding: 0.5rem; background-color: #fee; border-radius: 4px;}
.auth-button { width: 100%; padding: 0.75rem; color: white; border: none; border-radius: 4px; cursor: pointer;}
.auth-button:disabled { cursor: not-allowed;}
.auth-button.primary { background-color: #28a745;}
.auth-button.secondary { background-color: #007bff;}
.auth-links { margin-top: 1rem;}
/* Todos */
.todo-form { width: 100%;}
/* Todo List */.todos-list { display: flex; flex-direction: column; gap: 1rem;}
.todo-card { background: rgba(255, 255, 255, 0.03); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px; transition: all 0.2s ease; overflow: hidden; width: 100%; box-sizing: border-box;}
.todo-card:hover { border-color: rgba(255, 255, 255, 0.2); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);}
.todo-card.completed { opacity: 0.7;}
/* Todo Content */.todo-content { padding: 1rem 1.5rem;}
.todo-edit { padding: 1.5rem; min-height: 200px;}
.edit-fields { display: flex; flex-direction: column; gap: 1rem;}
.edit-actions { display: flex; gap: 1rem; margin-top: 1.5rem;}
.todo-header { display: flex; justify-content: space-between; align-items: center; gap: 1rem;}
.todo-title-btn { background: none; border: none; padding: 0; text-align: left; font-size: 1.25rem; font-weight: 600; color: white; cursor: pointer; transition: color 0.2s ease; flex: 1; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; max-width: calc(100% - 140px);}
.todo-title-btn:hover { color: #3b82f6;}
.todo-title-btn.completed { text-decoration: line-through; color: rgba(255, 255, 255, 0.5);}
.todo-actions { display: flex; gap: 0.5rem; flex-shrink: 0; min-width: 132px; justify-content: flex-end;}
/* Action Buttons */.action-btn { width: 40px; height: 40px; border: none; border-radius: 6px; cursor: pointer; background: rgba(255, 255, 255, 0.05); display: flex; align-items: center; justify-content: center; font-size: 18px; transition: all 0.2s ease; -webkit-text-fill-color: currentColor;}
.action-btn-complete { color: #10b981; font-size: 20px;}
.action-btn-complete:hover { background: rgba(16, 185, 129, 0.2); color: #34d399;}
.action-btn-edit { color: #3b82f6;}
.action-btn-edit:hover { background: rgba(59, 130, 246, 0.2); color: #60a5fa;}
.action-btn-delete { color: #ef4444;}
.action-btn-delete:hover { background: rgba(239, 68, 68, 0.2); color: #f87171;}
/* Add Todo Button */.add-todo-btn { width: 36px; height: 36px; border: none; border-radius: 6px; cursor: pointer; background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 18px; font-weight: normal; -webkit-text-fill-color: white; transition: all 0.2s ease;}
.add-todo-btn:hover { transform: scale(1.1); box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4);}
/* Todo Details */.todo-details { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid rgba(255, 255, 255, 0.1);}
.description { background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 1rem; margin-bottom: 1rem;}
.description p { margin: 0; color: rgba(255, 255, 255, 0.8); line-height: 1.6;}
.description.completed p { text-decoration: line-through; color: rgba(255, 255, 255, 0.5);}
.todo-meta { display: flex; justify-content: space-between; align-items: center; gap: 1rem;}
.meta-dates { display: flex; gap: 1rem; flex-wrap: wrap;}
.meta-item { font-size: 0.75rem; color: rgba(255, 255, 255, 0.5);}
.completion-badge { display: flex; align-items: center; gap: 0.25rem; font-size: 0.75rem; color: #10b981; font-weight: 500;}
.completion-icon { width: 0.875rem; height: 0.875rem;}
/* Responsive Design */@media (max-width: 768px) { .nav-container { padding: 1rem; flex-direction: column; gap: 1rem; }
.nav-links { gap: 1rem; flex-wrap: wrap; justify-content: center; }
.container { padding: 1rem; }
.form-actions { flex-direction: column; }
.edit-actions { flex-direction: column; }
.todo-header { flex-direction: column; align-items: flex-start; gap: 1rem; }
.todo-actions { align-self: stretch; justify-content: center; }
.meta-dates { flex-direction: column; gap: 0.25rem; }
.todo-meta { flex-direction: column; align-items: flex-start; gap: 0.5rem; }}
/* File Upload */.file-upload-btn { min-height: 120px; flex-direction: column; gap: 0.5rem; width: 100%; border: 2px dashed rgba(255, 255, 255, 0.3); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease;}
.file-upload-btn:hover { border-color: rgba(255, 255, 255, 0.5); background-color: rgba(255, 255, 255, 0.05);}
.file-upload-info { margin-top: 0.5rem; font-size: 0.875rem; color: rgba(255, 255, 255, 0.8);}
/* File Table */.file-table { width: 100%; border-collapse: collapse;}
.file-table th { padding: 0.75rem; text-align: left; border-bottom: 1px solid rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.7); font-weight: 500; font-size: 0.875rem;}
.file-table th:last-child { text-align: center;}
.file-table td { padding: 0.75rem; border-bottom: 1px solid rgba(255, 255, 255, 0.05);}
.file-table tr:hover { background-color: rgba(255, 255, 255, 0.02);}
.file-name { color: white; font-weight: 500;}
.file-meta { color: rgba(255, 255, 255, 0.6); font-size: 0.875rem;}
.file-actions { display: flex; gap: 0.5rem; justify-content: center;}
/* Responsive File Table */@media (max-width: 768px) { .file-table { font-size: 0.875rem; }
.file-table th, .file-table td { padding: 0.5rem; }
.file-actions { flex-direction: column; gap: 0.25rem; }}Run and test the Application
Section titled “Run and test the Application”Start the development server to test your route protection implementation:
npm run devThings to try out:
- Try navigating to
/profile- you should be redirected to the homepage/since you’re not authenticated. - Because you are not signed in, the navigation bar should only show the “Home” link and the placeholder for signin/signup links.
- On the homepage, you should see a message indicating that you are not signed in.
After we complete the next tutorial on user authentication, you will be able to sign in and access the protected /profile page and see how the navigation bar and homepage updates accordingly.
How It Works
Section titled “How It Works”- useAuth Composable: Manages authentication state using Nhost’s client and provides reactive state through Vue’s composition API
- Vue Router Guards: Navigation guards check authentication status before allowing access to protected routes
- Profile Page: A protected page that displays user information, only accessible when authenticated
- Automatic Redirects: Unauthenticated users are redirected to
/, authenticated users can access/profile
Key Features Demonstrated
Section titled “Key Features Demonstrated”Route Protection
Routes are protected using Vue Router navigation guards and authentication composable, preventing unauthorized access to sensitive areas.
Loading States
Smooth loading indicators are shown during authentication checks to improve user experience.
Automatic Redirects
Users are automatically redirected based on their authentication status, ensuring proper navigation flow.
Cross-tab Synchronization
Authentication state is synchronized across multiple browser tabs using Nhost’s session storage events.
Session Management
Complete user session and profile information is displayed and managed throughout the application.