7 min readUpdated May 24, 2025

Building Type-Safe Nuxt 3 Apps: Combining Composables, Pinia, and TypeScript for Scalable Authentication

Learn how to build a type-safe authentication system in Nuxt 3 using composables and Pinia, with TypeScript ensuring robust, scalable code. This tutorial walks you through creating a reusable useAuth composable that’s SSR-friendly and easy to integrate.

Written by

JavaScriptTypeScriptVue.jsReact, Vue and Typescript

Building Type-Safe Nuxt 3 Apps: Combining Composables, Pinia, and TypeScript for Scalable Authentication

Authentication is a cornerstone of modern web apps, but managing user state in Nuxt 3 can get tricky, especially with server-side rendering (SSR) and type safety in mind. In this post, we’ll explore how to combine Nuxt composables, Pinia, and TypeScript to create a robust, type-safe authentication system. By encapsulating auth logic in a composable and managing global state with Pinia, we’ll build a scalable solution that’s easy to maintain and SSR-friendly.

Why Composables, Pinia, and TypeScript?

Nuxt’s composables let us encapsulate reusable logic, making our code modular and testable. Pinia, the modern Vue store, offers a lightweight way to manage global state, like the current user. TypeScript adds type safety, catching errors early and improving developer experience, especially in complex apps. Together, they’re a powerhouse for building scalable Nuxt apps.

But challenges arise: How do we ensure Pinia stores work in SSR? How do we type composables to avoid runtime errors? Let’s dive into a practical example to find out.

The Problem: Managing Authentication State

Imagine building a Nuxt 3 app where users can log in, log out, and access protected routes. You need to:

  • Store the user’s data (e.g., name, email) globally.
  • Handle async API calls for login/logout.
  • Ensure type safety for API responses and state.
  • Make the logic reusable across components.
  • Support SSR without hydration errors.

A composable can handle the auth logic, while Pinia manages the user state. TypeScript will keep everything type-safe.

Solution: A Type-Safe useAuth Composable

Let’s create a useAuth composable that interacts with a Pinia store. Here’s how it works:

  1. Define a Pinia store for auth state.
  2. Create a composable to handle login/logout.
  3. Use TypeScript to type the data and API responses.
  4. Ensure SSR compatibility.

Step 1: Setting Up the Pinia Store

First, define a Pinia store to manage the user’s state.

1// stores/auth.ts 2import { defineStore } from 'pinia'; 3 4interface User { 5 id: string; 6 email: string; 7 name: string; 8} 9 10interface AuthState { 11 user: User | null; 12 token: string | null; 13} 14 15export const useAuthStore = defineStore('auth', { 16 state: (): AuthState => ({ 17 user: null, 18 token: null, 19 }), 20 actions: { 21 async login(credentials: { email: string; password: string }) { 22 // Mock API call 23 const response = await $fetch('/api/login', { 24 method: 'POST', 25 body: credentials, 26 }); 27 this.user = response.user; 28 this.token = response.token; 29 }, 30 logout() { 31 this.user = null; 32 this.token = null; 33 }, 34 }, 35});

We define a User interface for type safety and an AuthState interface for the store’s state. The login action simulates an API call using Nuxt’s $fetch, and logout clears the state.

Step 2: Creating the useAuth Composable

Now, let’s create a composable that uses the Pinia store and provides auth functionality.

1// composables/useAuth.ts 2import { computed } from 'vue'; 3import { useAuthStore } from '~/stores/auth'; 4 5interface Credentials { 6 email: string; 7 password: string; 8} 9 10interface LoginResponse { 11 user: { 12 id: string; 13 email: string; 14 name: string; 15 }; 16 token: string; 17} 18 19export function useAuth() { 20 const store = useAuthStore(); 21 22 const isAuthenticated = computed(() => !!store.user); 23 24 async function login(credentials: Credentials): Promise<LoginResponse | null> { 25 try { 26 await store.login(credentials); 27 return { user: store.user!, token: store.token! }; 28 } catch (error) { 29 console.error('Login failed:', error); 30 return null; 31 } 32 } 33 34 async function logout() { 35 store.logout(); 36 } 37 38 return { 39 user: store.user, 40 isAuthenticated, 41 login, 42 logout, 43 }; 44}

This composable:

  • Uses TypeScript interfaces (Credentials, LoginResponse) for type safety.
  • Wraps the store’s login action in a try-catch for error handling.
  • Provides a reactive isAuthenticated computed property.
  • Returns the user, login, and logout functions for use in components.

Step 3: Using the Composable in a Component

Here’s how you’d use useAuth in a Nuxt page or component:

1<!-- pages/login.vue --> 2<script setup lang="ts"> 3const { user, isAuthenticated, login, logout } = useAuth(); 4const credentials = ref({ email: '', password: '' }); 5 6async function handleLogin() { 7 const result = await login(credentials.value); 8 if (result) { 9 navigateTo('/dashboard'); 10 } 11} 12</script> 13 14<template> 15 <div> 16 <h1>Login</h1> 17 <form @submit.prevent="handleLogin"> 18 <input v-model="credentials.email" type="email" placeholder="Email" /> 19 <input v-model="credentials.password" type="password" placeholder="Password" /> 20 <button type="submit">Login</button> 21 </form> 22 <p v-if="isAuthenticated">Welcome, {{ user?.name }}!</p> 23 <button v-if="isAuthenticated" @click="logout">Logout</button> 24 </div> 25</template>

Step 4: Handling SSR in Nuxt

To ensure SSR compatibility:

  • Pinia in SSR: Pinia works out of the box with Nuxt 3’s SSR. The store’s state is automatically serialized and hydrated.
  • Async Composables: Use $fetch for API calls, as it’s SSR-safe in Nuxt.
  • Type Safety: TypeScript interfaces prevent runtime errors by ensuring API responses match expected shapes.

If you access the store outside a setup context (e.g., in middleware), use useNuxtApp().$pinia to initialize Pinia safely.

Best Practices

  1. Type Everything: Define interfaces for API responses, store state, and composable inputs/outputs to catch errors early.
  2. Keep Composables Focused: Each composable should handle one concern (e.g., auth logic) to stay reusable.
  3. Handle Errors Gracefully: Use try-catch in async composables to manage API failures.
  4. SSR Safety: Avoid accessing Pinia stores in global scope; use them within setup or composables.
  5. Test Locally: Use Nuxt’s auto-imports for composables and stores, but verify types with nuxi typecheck.

Conclusion

By combining Nuxt composables, Pinia, and TypeScript, we’ve built a scalable, type-safe authentication system that’s easy to reuse across components. The useAuth composable encapsulates logic, Pinia manages global state, and TypeScript ensures reliability. Try adapting this pattern for other features like data fetching or form validation, and share your experiments in the comments or on X!

For a live demo, check out this StackBlitz link (replace with your demo link). Want to dive deeper? Explore Nuxt’s composables docs or Pinia’s TypeScript guide.

About

Software Developer & Consultant specializing in JavaScript, TypeScript, and modern web technologies.

Share this article