This 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.
This is Part 2 in the Full-Stack Vue Development with Nhost series. This part builds a foundation for authentication-protected routes that you can extend to secure any part of your application.

Full-Stack Vue Development with Nhost

Prerequisites

  • An Nhost project set up
  • Node.js 20+ installed
  • Basic knowledge of Vue and Vue Router

Step-by-Step Guide

1

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 --router
cd nhost-vue-tutorial
npm install
2

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

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>
Replace <region> and <subdomain> with the actual values from your Nhost project dashboard.
4

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.
src/lib/nhost/auth.ts
import { createClient } from "@nhost/nhost-js";
import type { Session } from "@nhost/nhost-js/auth";
import { computed, reactive } from "vue";

// Global reactive state
const authState = reactive({
  user: null as Session["user"] | null,
  session: null as Session | null,
  isLoading: true,
});

// Create the nhost client
const nhost = createClient({
  region: (import.meta.env["VITE_NHOST_REGION"] as string) || "local",
  subdomain: (import.meta.env["VITE_NHOST_SUBDOMAIN"] as string) || "local",
});

// Subscription cleanup function
let 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 state
const 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 function
const 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 unload
if (typeof window !== "undefined") {
  window.addEventListener("beforeunload", cleanup);
}
5

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.
src/views/ProfileView.vue
<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>
6

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.
src/views/HomeView.vue
<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>
7

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.
src/components/Navigation.vue
<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>
8

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.
src/router/index.ts
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 routes
router.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;
9

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.
src/App.vue
<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>
10

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.
src/assets/main.css
: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;
  }
}
11

Run and test the Application

Start the development server to test your route protection implementation:
npm run dev
Things to try out:
  1. Try navigating to /profile - you should be redirected to the homepage / since you’re not authenticated.
  2. Because you are not signed in, the navigation bar should only show the “Home” link and the placeholder for signin/signup links.
  3. 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

  1. useAuth Composable: Manages authentication state using Nhost’s client and provides reactive state through Vue’s composition API
  2. Vue Router Guards: Navigation guards check authentication status before allowing access to protected routes
  3. Profile Page: A protected page that displays user information, only accessible when authenticated
  4. Automatic Redirects: Unauthenticated users are redirected to /, authenticated users can access /profile

Key Features Demonstrated