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:
- Define a Pinia store for auth state.
- Create a composable to handle login/logout.
- Use TypeScript to type the data and API responses.
- 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
- Type Everything: Define interfaces for API responses, store state, and composable inputs/outputs to catch errors early.
- Keep Composables Focused: Each composable should handle one concern (e.g., auth logic) to stay reusable.
- Handle Errors Gracefully: Use try-catch in async composables to manage API failures.
- SSR Safety: Avoid accessing Pinia stores in global scope; use them within
setup
or composables. - 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.