Szybka odpowiedź
Next.js 14 z TypeScript to potężne połączenie oferujące pełne wsparcie dla typów, nowy App Router oraz szereg optymalizacji wydajności. Kluczowe elementy to: ścisła konfiguracja TypeScript, wykorzystanie Route Handlers dla logiki serwerowej, walidacja danych z bibliotekami jak Zod, oraz optymalizacja poprzez incremental compilation. Ta kombinacja zapewnia skalowalność, bezpieczeństwo typów i doskonałą developer experience.
Wprowadzenie
Implementacja nowoczesnych aplikacji webowych wymaga solidnych fundamentów. Next.js 14 wraz z TypeScript stanowi idealne rozwiązanie dla projektów wymagających wysokiej jakości kodu, skalowalności i wydajności. Nowy App Router wprowadzony w Next.js 13 i udoskonalony w wersji 14 zmienia sposób myślenia o strukturze aplikacji.
W tym przewodniku przedstawimy kompleksowe podejście do implementacji aplikacji Next.js 14 z TypeScript, od konfiguracji projektu po zaawansowane wzorce architektoniczne. Szczególny nacisk położymy na praktyki, które sprawdzą się w projektach produkcyjnych w latach 2024-2025.
Inicjalizacja projektu Next.js 14 z TypeScript
Tworzenie nowego projektu
Rozpocznij od utworzenia projektu z pełnym wsparciem TypeScript:
npx create-next-app@latest moja-aplikacja --typescript --tailwind --app
# Opcje konfiguracji:
# ✓ Would you like to use TypeScript? → Yes
# ✓ Would you like to use ESLint? → Yes
# ✓ Would you like to use Tailwind CSS? → Yes
# ✓ Would you like to use `src/` directory? → Yes
# ✓ Would you like to use App Router? → Yes
# ✓ Would you like to customize the default import alias? → Yes
# ✓ What import alias would you like configured? → @/*
Struktura projektu App Router
moja-aplikacja/
├── src/
│ ├── app/ # App Router - strony i layouty
│ │ ├── layout.tsx # Root layout
│ │ ├── page.tsx # Strona główna
│ │ ├── api/ # API routes
│ │ │ └── hello/
│ │ │ └── route.ts # Route handler
│ │ └── (routes)/ # Grupowanie tras
│ ├── components/ # Komponenty React
│ ├── lib/ # Funkcje pomocnicze
│ └── types/ # Definicje typów TypeScript
├── public/ # Pliki statyczne
├── tsconfig.json # Konfiguracja TypeScript
└── next.config.js # Konfiguracja Next.js
Konfiguracja TypeScript dla produkcji
Optymalna konfiguracja tsconfig.json
{
"compilerOptions": {
// Ścisłe sprawdzanie typów
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
// Dodatkowe zabezpieczenia
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// Konfiguracja modułów
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
// Next.js specific
"jsx": "preserve",
"incremental": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
// Path aliases
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/types/*": ["./src/types/*"]
},
// Plugins
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Typowanie zmiennych środowiskowych
// src/types/env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: 'development' | 'production' | 'test'
NEXT_PUBLIC_API_URL: string
DATABASE_URL: string
JWT_SECRET: string
NEXT_PUBLIC_GA_ID?: string
}
}
}
export {}
Route Handlers z TypeScript
Podstawowy Route Handler
// src/app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
// Schema walidacji
const createUserSchema = z.object({
email: z.string().email('Nieprawidłowy adres email'),
password: z.string().min(8, 'Hasło musi mieć minimum 8 znaków'),
name: z.string().min(2, 'Imię musi mieć minimum 2 znaki')
})
// Typ dla odpowiedzi
type ApiResponse<T = any> = {
success: boolean
data?: T
error?: string
errors?: z.ZodError['errors']
}
export async function POST(request: NextRequest): Promise<NextResponse<ApiResponse>> {
try {
const body = await request.json()
// Walidacja danych
const validationResult = createUserSchema.safeParse(body)
if (!validationResult.success) {
return NextResponse.json({
success: false,
errors: validationResult.error.errors
}, { status: 400 })
}
// Logika biznesowa
const user = await createUser(validationResult.data)
return NextResponse.json({
success: true,
data: user
}, { status: 201 })
} catch (error) {
console.error('Error creating user:', error)
return NextResponse.json({
success: false,
error: 'Wystąpił błąd podczas tworzenia użytkownika'
}, { status: 500 })
}
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const page = Number(searchParams.get('page')) || 1
const limit = Number(searchParams.get('limit')) || 10
try {
const users = await getUsers({ page, limit })
return NextResponse.json({
success: true,
data: users,
meta: {
page,
limit,
total: users.total
}
})
} catch (error) {
return NextResponse.json({
success: false,
error: 'Nie udało się pobrać użytkowników'
}, { status: 500 })
}
}
Middleware z TypeScript
// src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyJWT } from '@/lib/auth'
export async function middleware(request: NextRequest) {
// Sprawdzanie autoryzacji dla chronionych tras
if (request.nextUrl.pathname.startsWith('/admin')) {
const token = request.cookies.get('auth-token')?.value
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
try {
const payload = await verifyJWT(token)
// Dodanie informacji o użytkowniku do nagłówków
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-user-id', payload.userId)
requestHeaders.set('x-user-role', payload.role)
return NextResponse.next({
request: {
headers: requestHeaders,
},
})
} catch (error) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
return NextResponse.next()
}
export const config = {
matcher: ['/admin/:path*', '/api/admin/:path*']
}
Server Components z TypeScript
Async Server Component
// src/app/products/page.tsx
import { Metadata } from 'next'
import { ProductCard } from '@/components/ProductCard'
import { getProducts } from '@/lib/api/products'
import { Product } from '@/types/product'
export const metadata: Metadata = {
title: 'Produkty - Zagor Digital',
description: 'Przeglądaj naszą ofertę produktów'
}
interface ProductsPageProps {
searchParams: {
category?: string
sort?: 'price' | 'name' | 'date'
page?: string
}
}
export default async function ProductsPage({ searchParams }: ProductsPageProps) {
const page = Number(searchParams.page) || 1
const products = await getProducts({
category: searchParams.category,
sort: searchParams.sort || 'date',
page,
limit: 12
})
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Nasze produkty</h1>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6">
{products.items.map((product: Product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{products.totalPages > 1 && (
<Pagination
currentPage={page}
totalPages={products.totalPages}
baseUrl="/products"
searchParams={searchParams}
/>
)}
</div>
)
}
Error Boundary z TypeScript
// src/app/products/error.tsx
'use client'
import { useEffect } from 'react'
import { Button } from '@/components/ui/button'
interface ErrorProps {
error: Error & { digest?: string }
reset: () => void
}
export default function Error({ error, reset }: ErrorProps) {
useEffect(() => {
// Log błędu do systemu monitorowania
console.error('Product page error:', error)
}, [error])
return (
<div className="flex flex-col items-center justify-center min-h-[400px]">
<h2 className="text-2xl font-bold mb-4">Coś poszło nie tak!</h2>
<p className="text-muted-foreground mb-6">
Nie udało się załadować produktów. Spróbuj ponownie.
</p>
<Button onClick={reset}>Spróbuj ponownie</Button>
</div>
)
}
Wzorce architektoniczne
Repository Pattern
// src/lib/repositories/userRepository.ts
import { User, CreateUserDTO, UpdateUserDTO } from '@/types/user'
import { db } from '@/lib/db'
export class UserRepository {
async findById(id: string): Promise<User | null> {
return await db.user.findUnique({
where: { id }
})
}
async findByEmail(email: string): Promise<User | null> {
return await db.user.findUnique({
where: { email }
})
}
async create(data: CreateUserDTO): Promise<User> {
return await db.user.create({
data: {
...data,
createdAt: new Date()
}
})
}
async update(id: string, data: UpdateUserDTO): Promise<User> {
return await db.user.update({
where: { id },
data: {
...data,
updatedAt: new Date()
}
})
}
async delete(id: string): Promise<void> {
await db.user.delete({
where: { id }
})
}
}
// Singleton instance
export const userRepository = new UserRepository()
Service Layer
// src/lib/services/userService.ts
import { userRepository } from '@/lib/repositories/userRepository'
import { CreateUserDTO, User } from '@/types/user'
import { hashPassword } from '@/lib/auth'
import { sendWelcomeEmail } from '@/lib/email'
export class UserService {
async createUser(data: CreateUserDTO): Promise<User> {
// Sprawdzenie czy użytkownik już istnieje
const existingUser = await userRepository.findByEmail(data.email)
if (existingUser) {
throw new Error('Użytkownik z tym adresem email już istnieje')
}
// Hashowanie hasła
const hashedPassword = await hashPassword(data.password)
// Tworzenie użytkownika
const user = await userRepository.create({
...data,
password: hashedPassword
})
// Wysłanie emaila powitalnego
await sendWelcomeEmail(user.email, user.name)
return user
}
async getUserProfile(userId: string): Promise<Omit<User, 'password'>> {
const user = await userRepository.findById(userId)
if (!user) {
throw new Error('Użytkownik nie znaleziony')
}
// Usunięcie hasła z odpowiedzi
const { password, ...userWithoutPassword } = user
return userWithoutPassword
}
}
export const userService = new UserService()
Optymalizacja wydajności
Dynamic Imports z TypeScript
// src/components/DynamicChart.tsx
import dynamic from 'next/dynamic'
import { Skeleton } from '@/components/ui/skeleton'
import type { ChartProps } from '@/types/chart'
const Chart = dynamic<ChartProps>(
() => import('@/components/Chart').then(mod => mod.Chart),
{
loading: () => <Skeleton className="h-[400px] w-full" />,
ssr: false
}
)
export function DynamicChart(props: ChartProps) {
return <Chart {...props} />
}
Memoizacja i optymalizacja re-renderów
// src/components/ExpensiveList.tsx
import { memo, useMemo, useCallback } from 'react'
interface ListItem {
id: string
name: string
value: number
}
interface ExpensiveListProps {
items: ListItem[]
onItemClick: (id: string) => void
}
export const ExpensiveList = memo<ExpensiveListProps>(({ items, onItemClick }) => {
// Memoizacja obliczeń
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => b.value - a.value)
}, [items])
// Memoizacja callbacków
const handleClick = useCallback((id: string) => {
onItemClick(id)
}, [onItemClick])
return (
<ul className="space-y-2">
{sortedItems.map(item => (
<li
key={item.id}
onClick={() => handleClick(item.id)}
className="p-4 border rounded cursor-pointer hover:bg-gray-50"
>
<span className="font-medium">{item.name}</span>
<span className="ml-auto">{item.value}</span>
</li>
))}
</ul>
)
}, (prevProps, nextProps) => {
// Custom porównanie props
return (
prevProps.items.length === nextProps.items.length &&
prevProps.items.every((item, index) => item.id === nextProps.items[index].id)
)
})
ExpensiveList.displayName = 'ExpensiveList'
Testowanie z TypeScript
Testy jednostkowe
// __tests__/services/userService.test.ts
import { userService } from '@/lib/services/userService'
import { userRepository } from '@/lib/repositories/userRepository'
import { hashPassword } from '@/lib/auth'
// Mock dependencies
jest.mock('@/lib/repositories/userRepository')
jest.mock('@/lib/auth')
jest.mock('@/lib/email')
describe('UserService', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('createUser', () => {
it('should create a new user successfully', async () => {
const mockUser = {
id: '1',
email: 'test@example.com',
name: 'Test User',
password: 'hashed_password'
}
;(userRepository.findByEmail as jest.Mock).mockResolvedValue(null)
;(hashPassword as jest.Mock).mockResolvedValue('hashed_password')
;(userRepository.create as jest.Mock).mockResolvedValue(mockUser)
const result = await userService.createUser({
email: 'test@example.com',
name: 'Test User',
password: 'password123'
})
expect(result).toEqual(mockUser)
expect(userRepository.findByEmail).toHaveBeenCalledWith('test@example.com')
expect(hashPassword).toHaveBeenCalledWith('password123')
})
it('should throw error if user already exists', async () => {
;(userRepository.findByEmail as jest.Mock).mockResolvedValue({ id: '1' })
await expect(userService.createUser({
email: 'existing@example.com',
name: 'Test User',
password: 'password123'
})).rejects.toThrow('Użytkownik z tym adresem email już istnieje')
})
})
})
Testy integracyjne
// __tests__/api/users.test.ts
import { createMocks } from 'node-mocks-http'
import { POST } from '@/app/api/users/route'
import { NextRequest } from 'next/server'
describe('/api/users', () => {
describe('POST', () => {
it('should create user with valid data', async () => {
const body = {
email: 'test@example.com',
password: 'password123',
name: 'Test User'
}
const { req } = createMocks({
method: 'POST',
body
})
const request = new NextRequest('http://localhost:3000/api/users', {
method: 'POST',
body: JSON.stringify(body)
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(201)
expect(data.success).toBe(true)
expect(data.data).toHaveProperty('id')
})
})
})
Bezpieczeństwo i walidacja
Walidacja z Zod
// src/lib/validation/schemas.ts
import { z } from 'zod'
// Schema dla formularza kontaktowego
export const contactFormSchema = z.object({
name: z.string()
.min(2, 'Imię musi mieć minimum 2 znaki')
.max(50, 'Imię może mieć maksymalnie 50 znaków'),
email: z.string()
.email('Nieprawidłowy adres email'),
phone: z.string()
.regex(/^\+?[0-9]{9,15}$/, 'Nieprawidłowy numer telefonu')
.optional(),
message: z.string()
.min(10, 'Wiadomość musi mieć minimum 10 znaków')
.max(1000, 'Wiadomość może mieć maksymalnie 1000 znaków'),
consent: z.boolean()
.refine(val => val === true, 'Musisz wyrazić zgodę')
})
// Typ TypeScript z schema
export type ContactFormData = z.infer<typeof contactFormSchema>
// Helper do walidacji
export async function validateData<T>(
schema: z.ZodSchema<T>,
data: unknown
): Promise<{ success: true; data: T } | { success: false; errors: z.ZodError }> {
try {
const validData = await schema.parseAsync(data)
return { success: true, data: validData }
} catch (error) {
if (error instanceof z.ZodError) {
return { success: false, errors: error }
}
throw error
}
}
Sanityzacja danych
// src/lib/security/sanitize.ts
import DOMPurify from 'isomorphic-dompurify'
export function sanitizeHtml(dirty: string): string {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'target', 'rel']
})
}
export function sanitizeInput(input: string): string {
return input
.trim()
.replace(/[<>]/g, '') // Usunięcie podstawowych tagów HTML
.slice(0, 1000) // Ograniczenie długości
}
// SQL Injection prevention z Prisma
export function createSafeQuery(searchTerm: string) {
// Prisma automatycznie zabezpiecza przed SQL Injection
return {
where: {
OR: [
{ name: { contains: searchTerm, mode: 'insensitive' } },
{ description: { contains: searchTerm, mode: 'insensitive' } }
]
}
}
}
Deployment i CI/CD
Konfiguracja dla Vercel
// next.config.js z TypeScript
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// Włączenie strict mode React
reactStrictMode: true,
// Konfiguracja obrazów
images: {
domains: ['cdn.zagordigital.pl'],
formats: ['image/avif', 'image/webp'],
},
// Zmienne środowiskowe
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL!,
},
// Optymalizacje produkcyjne
swcMinify: true,
// Konfiguracja dla TypeScript
typescript: {
// Nie zatrzymuj builda przy błędach TypeScript (nie zalecane dla produkcji)
ignoreBuildErrors: false,
},
// Experimental features
experimental: {
typedRoutes: true, // Włącz typowane routing
},
}
export default nextConfig
GitHub Actions CI
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run type-check
- name: Lint
run: npm run lint
- name: Test
run: npm run test:ci
- name: Build
run: npm run build
Najczęściej zadawane pytania
Jak migrować z Pages Router do App Router?
Migracja wymaga przepisania komponentów stron na Server Components i przeniesienia API routes do struktury route.ts. Kluczowe zmiany to usunięcie getServerSideProps na rzecz bezpośredniego pobierania danych w komponentach.
Czy powinienem używać Server Components wszędzie?
Nie, używaj Server Components dla stron i komponentów nie wymagających interakcji. Client Components są niezbędne dla formularzy, stanów lokalnych i event handlerów.
Jak radzić sobie z typowaniem dynamicznych tras?
Next.js 14 wprowadza experimental typedRoutes które automatycznie generują typy dla tras. Alternatywnie możesz utworzyć własne typy dla params.
Jaka jest różnica między route.ts a page.tsx?
route.ts służy do tworzenia API endpoints (zastępuje pages/api), podczas gdy page.tsx renderuje strony HTML. Nie mogą współistnieć w tym samym folderze.
Jak optymalizować bundle size z TypeScript?
Używaj dynamic imports, tree shaking, oraz analizuj bundle za pomocą @next/bundle-analyzer. Upewnij się że tsconfig ma włączone isolatedModules.
Czy TypeScript spowalnia development?
Początkowa kompilacja może być wolniejsza, ale incremental compilation i cache znacząco przyspieszają kolejne buildy. Używaj npm run dev z hot reload.
Jak testować Server Components?
Testuj logikę biznesową osobno od komponentów. Dla testów integracyjnych używaj Playwright lub Cypress które renderują pełną aplikację.
Podsumowanie i kolejne kroki
Implementacja z Next.js 14 i TypeScript wymaga przemyślanego podejścia do architektury, ale zapewnia solidne fundamenty dla skalowalnych aplikacji. Kluczowe elementy sukcesu to ścisła konfiguracja TypeScript, wykorzystanie możliwości App Router oraz konsekwentne stosowanie wzorców architektonicznych.
Następne kroki:
- Skonfiguruj projekt z przedstawionymi ustawieniami TypeScript
- Zaimplementuj podstawowe Route Handlers z walidacją
- Stwórz reużywalne komponenty z właściwym typowaniem
- Dodaj testy jednostkowe i integracyjne
- Skonfiguruj CI/CD pipeline
Potrzebujesz wsparcia w implementacji aplikacji Next.js? Skontaktuj się z nami - zespół Zagor Digital pomoże w stworzeniu wydajnej i skalowalnej aplikacji.
Ostatnia aktualizacja: 26 stycznia 2025