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.
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
-
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
-
JWT Token Management:
- Tokens are stored based on "Remember me" preference
- Separate storage for persistent and session tokens
- Token refresh functionality included
-
TanStack Query Benefits:
- Automatic cache invalidation and updates
- Loading and error states handled automatically
- Optimistic updates for better UX
- Request deduplication
-
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.