Next JS with @tanstack/query and DDD

Learn how to build a production-ready authentication system in Next.js 15 using TanStack Query and Domain-Driven Design principles.

Next JS with @tanstack/query and DDD
Next JS with @tanstack/query and DDD

Authentication is a critical aspect of modern web applications. In this article, we'll build a complete authentication system using Next.js 15 and TanStack Query, following Domain-Driven Design (DDD) principles. We'll create a production-ready authentication flow with features like JWT tokens, password reset, and "remember me" functionality.

What is DDD

Domain-Driven Design (DDD) is a software development methodology that focuses on creating a model of the business domain in software. Let me break this down in simpler terms.

Key Features Explained

  1. Domain-Driven Design (DDD):

    • Clear separation of concerns with domain, application, infrastructure, and presentation layers
    • Domain types and validation rules are centralized
    • Business logic is isolated in the application layer
  2. JWT Token Management:

    • Tokens are stored based on "Remember me" preference
    • Separate storage for persistent and session tokens
    • Token refresh functionality included
  3. TanStack Query Benefits:

    • Automatic cache invalidation and updates
    • Loading and error states handled automatically
    • Optimistic updates for better UX
    • Request deduplication
  4. Type Safety:

    • Full TypeScript support throughout the application
    • Zod schema validation for runtime type checking
    • Type inference from API responses

Project Structure

Following DDD patterns, we'll organize our code into the following structure

src/
├── domain/
│   └── auth/
│       ├── types.ts
│       └── validation.ts
├── application/
│   └── auth/
│       ├── useAuth.ts
│       └── mutations.ts
├── infrastructure/
│   └── auth/
│       ├── api.ts
│       └── storage.ts
└── presentation/
    └── auth/
        ├── LoginForm.tsx
        ├── ForgotPassword.tsx
        └── ResetPassword.tsx

Domain Layer

The Domain Layer is the heart of your software where the core business logic and rules live. Let me break it down with practical examples

  • Contains business rules and logic
  • Pure business concepts with no technical details
// src/domain/auth/types.ts
export interface LoginCredentials {
  email: string;
  password: string;
  rememberMe?: boolean;
}

export interface AuthResponse {
  token: string;
  user: {
    id: string;
    email: string;
    name: string;
  }
}

export interface ResetPasswordPayload {
  token: string;
  newPassword: string;
}
// src/domain/auth/validation.ts
import { z } from 'zod'

export const loginSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  rememberMe: z.boolean().optional()
})

export const resetPasswordSchema = z.object({
  token: z.string(),
  newPassword: z.string().min(8, 'Password must be at least 8 characters')
})

Infrastructure Layer

The infrastructure layer handles API calls and token storage.

  • Provides technical capabilities
  • Handles database access, messaging, external APIs
// src/infrastructure/auth/api.ts
import axios from 'axios'
import type { LoginCredentials, AuthResponse, ResetPasswordPayload } from '@/domain/auth/types'

const API_BASE_URL = '/api/auth'

export const authApi = {
  async login(credentials: LoginCredentials): Promise {
    const { data } = await axios.post(`${API_BASE_URL}/login`, credentials)
    return data
  },

  async forgotPassword(email: string): Promise {
    await axios.post(`${API_BASE_URL}/forgot-password`, { email })
  },

  async resetPassword(payload: ResetPasswordPayload): Promise {
    await axios.post(`${API_BASE_URL}/reset-password`, payload)
  },

  async refreshToken(token: string): Promise {
    const { data } = await axios.post(`${API_BASE_URL}/refresh`, { token })
    return data
  }
}

// src/infrastructure/auth/storage.ts
export const tokenStorage = {
  getToken: () => localStorage.getItem('auth_token'),
  setToken: (token: string) => localStorage.setItem('auth_token', token),
  removeToken: () => localStorage.removeItem('auth_token'),
  
  getPersistentToken: () => localStorage.getItem('persistent_token'),
  setPersistentToken: (token: string) => localStorage.setItem('persistent_token', token),
  removePersistentToken: () => localStorage.removeItem('persistent_token')
}

Application Layer

The application layer contains our TanStack Query hooks and business logic.

  • Coordinates application tasks
  • Directs workflow
  • Doesn't contain business rules
// src/application/auth/mutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { authApi } from '@/infrastructure/auth/api'
import { tokenStorage } from '@/infrastructure/auth/storage'

export const useLogin = () => {
  const queryClient = useQueryClient()
  
  return useMutation({
    mutationFn: authApi.login,
    onSuccess: (data, variables) => {
      // Store token based on rememberMe preference
      if (variables.rememberMe) {
        tokenStorage.setPersistentToken(data.token)
      } else {
        tokenStorage.setToken(data.token)
      }
      
      queryClient.setQueryData(['user'], data.user)
    }
  })
}

export const useForgotPassword = () => {
  return useMutation({
    mutationFn: authApi.forgotPassword
  })
}

export const useResetPassword = () => {
  return useMutation({
    mutationFn: authApi.resetPassword
  })
}

Presentation Layer

Finally, let's implement our UI components.

  • Handles user interface and API endpoints
  • Shows information to users
  • Accepts user commands
// src/presentation/auth/LoginForm.tsx
import { useState } from 'react'
import { useLogin } from '@/application/auth/mutations'
import { loginSchema } from '@/domain/auth/validation'

export const LoginForm = () => {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [rememberMe, setRememberMe] = useState(false)
  const login = useLogin()

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    
    try {
      const credentials = { email, password, rememberMe }
      await loginSchema.parseAsync(credentials)
      await login.mutateAsync(credentials)
    } catch (error) {
      // Handle validation/login errors
    }
  }

  return (
        Email
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="w-full p-2 border rounded"
        />
        Password
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          className="w-full p-2 border rounded"
        />
        <input
          id="rememberMe"
          type="checkbox"
          checked={rememberMe}
          onChange={(e) => setRememberMe(e.target.checked)}
        />
          Remember me
        {login.isPending ? 'Logging in...' : 'Login'}
  )
}
// src/presentation/auth/ForgotPassword.tsx
import { useState } from 'react'
import { useForgotPassword } from '@/application/auth/mutations'

export const ForgotPassword = () => {
  const [email, setEmail] = useState('')
  const forgotPassword = useForgotPassword()

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    await forgotPassword.mutateAsync(email)
  }

  return (
        Email
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="w-full p-2 border rounded"
        />
        {forgotPassword.isPending ? 'Sending...' : 'Reset Password'}
  )
}

API Route Handlers

For completeness, here are example API route handlers in Next.js:

// src/app/api/auth/login/route.ts
import { NextResponse } from 'next/server'
import jwt from 'jsonwebtoken'
import { loginSchema } from '@/domain/auth/validation'

export async function POST(req: Request) {
  try {
    const body = await req.json()
    const { email, password } = await loginSchema.parseAsync(body)
    
    // Validate credentials against your database
    // ...
    
    // Generate JWT token
    const token = jwt.sign(
      { userId: 'user_id', email },
      process.env.JWT_SECRET!,
      { expiresIn: '1d' }
    )
    
    return NextResponse.json({
      token,
      user: {
        id: 'user_id',
        email,
        name: 'User Name'
      }
    })
  } catch (error) {
    return NextResponse.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    )
  }
}


Conclusion

This implementation provides a solid foundation for authentication in a Next.js application. The DDD approach ensures the code is maintainable and scalable, while TanStack Query handles the complex state management and caching requirements of modern web applications.

Remember to:
- Implement proper error handling
- Add loading states and error messages in UI components
- Set up proper JWT token rotation
- Add rate limiting for security
- Implement proper password hashing on the backend
- Add CSRF protection
- Set secure HTTP-only cookies for tokens

The complete implementation can be extended based on your specific requirements while maintaining the clean architecture principles demonstrated here.