This is a dashboard starter template for the NextJS 14 app router based on Auth.js v5.
- Next.js 14
- Auth.js v5 + Prisma Adapter
- Tailwindcss
- Shadcn
- Prisma
- Zustand
- React Query
- Nodemailer
- Browserslist
- bcrypt.js
- Jose (JSONWebToken)
- Day.js
- qs
- cookies-next
- React Icons
The folder and file structure is based on nextjs app router next.js project structure.
.
├── actions/ # Server Actions
├── app/ # App Router
│ └── api/
│ ├── auth/ # Authentication
│ └── v1/ # Public APIs
├── components/ # React components
├── config/ # Configuration for site
├── context/ # Context
├── docs/ # Documents
├── hooks/ # Hooks
├── lib/ # Utility functions
├── prisma/ # Prisma Schema Location and Configuration
├── public/ # Static assets to be served
│ └── [locales]/ # Internationalization
├── queries/ # API
├── schemas/ # Schema validations
├── screenshots/ # Screenshots
├── store/ # State
├── types/ # Type definitions
└── package.json
Clone the repository to the current directory.
git clone https://github.com/w3labkr/nextjs14-authjs5-dashboard.git .
Install all modules listed as dependencies.
npm install
Copy of the .env.example
if the .env
doesn't exist.
cp .env.example .env
Create an SQL migration file and execute it.
npx prisma migrate dev --name init
Start the development server.
npm run dev
All Server Actions can be invoked by plain
<form>
, which could open them up to CSRF attacks. Behind the scenes, Server Actions are always implemented using POST and only this HTTP method is allowed to invoke them. This alone prevents most CSRF vulnerabilities in modern browsers, particularly due to Same-Site cookies being the default.As an additional protection Server Actions in Next.js 14 also compares the Origin header to the Host header (or X-Forwarded-Host). If they don't match, the Action will be rejected. In other words, Server Actions can only be invoked on the same host as the page that hosts it. Very old unsupported and outdated browsers that don't support the Origin header could be at risk.
Server Actions doesn't use CSRF tokens, therefore HTML sanitization is crucial.
When Custom Route Handlers (route.tsx) are used instead, extra auditing can be necessary since CSRF protection has to be done manually there. The traditional rules apply there.
How to Think About Security in Next.js
ApiResponse
import { ApiResponse } from '@/lib/http'
export async function POST(req) {
return ApiResponse.json(null)
// { status: 'success', success: true, message: 'OK', data: null }
return ApiResponse.json({})
// { status: 'success', success: true, message: 'OK', data: {} }
return ApiResponse.json({ message: 'hi' })
// { status: 'success', success: true, message: 'hi', data: {} }
return ApiResponse.json({ user: null })
// { status: 'success', success: true, message: 'OK', data: { user: null } }
return ApiResponse.json({ user: null, message: 'hi' })
// { status: 'success', success: true, message: 'hi', data: { user: null } }
return ApiResponse.json({ user: null }, { status: 400 })
// { status: 'fail', success: false, message: 'Bad Request', data: { user: null } }
return ApiResponse.json({ user: null, message: 'hi' }, { status: 400 })
// { status: 'fail', success: false, message: 'hi', data: { user: null } }
}
http status codes and text
import {
STATUS_CODES,
STATUS_TEXTS,
STATUS_CODE_TO_TEXT,
STATUS_TEXT_TO_CODE
} from '@/lib/http'
STATUS_CODES.OK // 200
STATUS_TEXTS.OK // "OK"
STATUS_CODE_TO_TEXT["200"] // "OK"
STATUS_TEXT_TO_CODE["OK"] // "200"
Password encryption and verification
import { generateHash, compareHash } from '@/lib/bcrypt'
const hashed = await generateHash('hash')
if (await compareHash('hash', hashed)) {
// isMatch
}
Client-side CSRF protection
'use client'
import { useCsrfToken } from '@/hooks/use-csrf-token'
export function Component() {
const csrfToken = useCsrfToken()
async function onSubmit() {
const res = await fetch('/api', {
headers: { 'X-CSRF-Token': csrfToken }
})
}
return <button onClick={onSubmit}>Submit</button>
}
Server-side CSRF protection
import { verifyCsrfToken } from '@/lib/crypto'
export async function POST(req) {
if (!verifyCsrfToken(req)) {
return new Response('Unauthorized', { status: 401 })
}
}
Sending mail
import { transporter, sender } from '@/lib/nodemailer'
try {
const info = await transporter.sendMail({
from: `"${sender?.name}" <${sender?.email}>`,
to: "receiver@sender.com",
subject: "Message title",
text: "Plaintext version of the message",
html: "<p>HTML version of the message</p>",
})
} catch (e) {
console.log(e)
}
The time zone and localized format are set.
import dayjs from '@/lib/dayjs'
dayjs().toISOString()
LucideIcon
import { LucideIcon } from '@/components/lucide-icon'
<LucideIcon name="Heart"/>
JWT
import {
decodeJwt,
verifyJwt,
jwtSign,
generateRecoveryToken,
generateAccessToken,
generateRefreshToken,
generateTokenExpiresAt,
isTokenExpired
} from '@/lib/jwt'
decodeJwt(jwt: string): PayloadType & JWTPayload
verifyJwt(jwt: string | Uint8Array, options?: JWTVerifyOptions): Promise<JSON | null>
jwtSign(sub: string, exp: number | string | Date = '1h', payload?: JWTPayload): Promise<string>
generateRecoveryToken(sub: string, payload?: JWTPayload): Promise<string>
generateAccessToken(sub: string): Promise<string>
generateRefreshToken(sub: string, jwt?: string | null): Promise<string>
generateTokenExpiresAt(expiresIn: number = 60 * 60): number
isTokenExpired(expiresAt: number, options?: { expiresIn?: number; expiresBefore?: number}): boolean
bcrypt
import {
generateHash,
compareHash
} from '@/lib/bcrypt'
generateHash(s: string): Promise<string>
compareHash(s: string, hash: string): Promise<boolean>
crypto
import {
uuidv4,
generateCsrfToken,
verifyCsrfToken,
verifyAjax,
verifyCsrfAndAjax,
} from '@/lib/crypto'
uuidv4(): `${string}-${string}-${string}-${string}-${string}`
generateCsrfToken(size: number = 32): string
verifyCsrfToken(req: NextRequest): boolean
verifyAjax(req: NextRequest): boolean
verifyCsrfAndAjax(req: NextRequest): boolean
math
import {
getRandom,
getRandomArbitrary,
getRandomInt,
getRandomIntInclusive
} from '@/lib/math'
getRandom()
getRandomArbitrary(min: number, max: number)
getRandomInt(min: number, max: number)
getRandomIntInclusive(min: number, max: number)
utils
import {
cn,
wait,
fetcher,
absoluteUrl,
relativeUrl
} from '@/lib/utils'
cn(...inputs: ClassValue[]): string
wait(milliseconds: number): Promise<void>
fetcher(input: RequestInfo | URL, init?: RequestInit): Promise<JSON>
absoluteUrl(url: string | URL, base?: string | URL): string
relativeUrl(url: string | URL, base?: string | URL): string
This software license under the MIT License.