Next.js Static Assets
Static assets are an important part of web applications, including images, fonts, icons, and other files. Next.js provides powerful static asset handling capabilities. This chapter will detail how to optimize and manage these resources.
Static Assets Basics
The public Directory
Next.js uses the public directory to store static assets:
public/
├── favicon.ico
├── logo.png
├── images/
│ ├── hero.jpg
│ ├── avatar.png
│ └── gallery/
│ ├── photo1.jpg
│ └── photo2.jpg
├── icons/
│ ├── home.svg
│ └── user.svg
├── fonts/
│ ├── custom-font.woff2
│ └── custom-font.woff
└── documents/
└── manual.pdfAccessing Static Assets
typescript
// Access directly using path
export default function HomePage() {
return (
<div>
<img src="/logo.png" alt="Logo" />
<img src="/images/hero.jpg" alt="Hero" />
<a href="/documents/manual.pdf" download>
Download Manual
</a>
</div>
)
}Image Optimization
Next.js Image Component
typescript
// components/OptimizedImage.tsx
import Image from 'next/image'
export default function OptimizedImage() {
return (
<div>
{/* Basic usage */}
<Image
src="/images/hero.jpg"
alt="Hero Image"
width={800}
height={400}
/>
{/* Responsive image */}
<Image
src="/images/hero.jpg"
alt="Hero Image"
fill
style={{ objectFit: 'cover' }}
/>
{/* Priority loading */}
<Image
src="/images/hero.jpg"
alt="Hero Image"
width={800}
height={400}
priority
/>
</div>
)
}External Images
typescript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ['example.com', 'cdn.example.com'],
// Or use remotePatterns (recommended)
remotePatterns: [
{
protocol: 'https',
hostname: 'example.com',
port: '',
pathname: '/images/**',
},
{
protocol: 'https',
hostname: 'cdn.example.com',
},
],
},
}
module.exports = nextConfigtypescript
// Using external images
import Image from 'next/image'
export default function ExternalImage() {
return (
<Image
src="https://example.com/images/photo.jpg"
alt="External Photo"
width={600}
height={400}
/>
)
}Dynamic Images
typescript
// components/DynamicImage.tsx
import Image from 'next/image'
import { useState } from 'react'
interface DynamicImageProps {
src: string
alt: string
width: number
height: number
}
export default function DynamicImage({ src, alt, width, height }: DynamicImageProps) {
const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
return (
<div className="relative">
{isLoading && (
<div className="absolute inset-0 bg-gray-200 animate-pulse rounded" />
)}
<Image
src={src}
alt={alt}
width={width}
height={height}
onLoad={() => setIsLoading(false)}
onError={() => {
setIsLoading(false)
setHasError(true)
}}
className={`transition-opacity duration-300 ${
isLoading ? 'opacity-0' : 'opacity-100'
}`}
/>
{hasError && (
<div className="absolute inset-0 bg-gray-100 flex items-center justify-center">
<span className="text-gray-500">Image failed to load</span>
</div>
)}
</div>
)
}Image Gallery
typescript
// components/ImageGallery.tsx
'use client'
import Image from 'next/image'
import { useState } from 'react'
interface ImageItem {
id: string
src: string
alt: string
width: number
height: number
}
interface ImageGalleryProps {
images: ImageItem[]
}
export default function ImageGallery({ images }: ImageGalleryProps) {
const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null)
return (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{images.map((image) => (
<div
key={image.id}
className="cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setSelectedImage(image)}
>
<Image
src={image.src}
alt={image.alt}
width={300}
height={200}
className="rounded-lg object-cover"
/>
</div>
))}
</div>
{/* Modal */}
{selectedImage && (
<div
className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50"
onClick={() => setSelectedImage(null)}
>
<div className="relative max-w-4xl max-h-full p-4">
<Image
src={selectedImage.src}
alt={selectedImage.alt}
width={selectedImage.width}
height={selectedImage.height}
className="max-w-full max-h-full object-contain"
/>
<button
className="absolute top-4 right-4 text-white text-2xl"
onClick={() => setSelectedImage(null)}
>
✕
</button>
</div>
</div>
)}
</>
)
}Font Optimization
Google Fonts
typescript
// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
})
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={inter.className}>
<body>
<div className={robotoMono.className}>
Code font example
</div>
{children}
</body>
</html>
)
}Local Fonts
typescript
// app/layout.tsx
import localFont from 'next/font/local'
const customFont = localFont({
src: [
{
path: '../public/fonts/custom-regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../public/fonts/custom-bold.woff2',
weight: '700',
style: 'normal',
},
],
display: 'swap',
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={customFont.className}>
<body>{children}</body>
</html>
)
}Font Variables
typescript
// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
})
const robotoMono = Roboto_Mono({
subsets: ['latin'],
variable: '--font-roboto-mono',
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
<body>{children}</body>
</html>
)
}css
/* globals.css */
:root {
--font-inter: 'Inter', sans-serif;
--font-roboto-mono: 'Roboto Mono', monospace;
}
body {
font-family: var(--font-inter);
}
code {
font-family: var(--font-roboto-mono);
}Icon Management
SVG Icon Components
typescript
// components/icons/HomeIcon.tsx
export default function HomeIcon({
size = 24,
color = 'currentColor'
}: {
size?: number
color?: string
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 9L12 2L21 9V20C21 20.5304 20.7893 21.0391 20.4142 21.4142C20.0391 21.7893 19.5304 22 19 22H5C4.46957 22 3.96086 21.7893 3.58579 21.4142C3.21071 21.0391 3 20.5304 3 20V9Z"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9 22V12H15V22"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}Icon Library
typescript
// components/icons/index.tsx
import HomeIcon from './HomeIcon'
import UserIcon from './UserIcon'
import SettingsIcon from './SettingsIcon'
export const icons = {
home: HomeIcon,
user: UserIcon,
settings: SettingsIcon,
} as const
export type IconName = keyof typeof icons
interface IconProps {
name: IconName
size?: number
color?: string
className?: string
}
export default function Icon({ name, size = 24, color = 'currentColor', className }: IconProps) {
const IconComponent = icons[name]
return (
<IconComponent
size={size}
color={color}
className={className}
/>
)
}Using Icons
typescript
// components/Navigation.tsx
import Icon from '@/components/icons'
export default function Navigation() {
return (
<nav className="flex space-x-4">
<a href="/" className="flex items-center space-x-2">
<Icon name="home" size={20} />
<span>Home</span>
</a>
<a href="/profile" className="flex items-center space-x-2">
<Icon name="user" size={20} />
<span>Profile</span>
</a>
<a href="/settings" className="flex items-center space-x-2">
<Icon name="settings" size={20} />
<span>Settings</span>
</a>
</nav>
)
}File Downloads
Download Component
typescript
// components/DownloadButton.tsx
'use client'
import { useState } from 'react'
interface DownloadButtonProps {
url: string
filename: string
children: React.ReactNode
}
export default function DownloadButton({ url, filename, children }: DownloadButtonProps) {
const [downloading, setDownloading] = useState(false)
const handleDownload = async () => {
setDownloading(true)
try {
const response = await fetch(url)
const blob = await response.blob()
const downloadUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(downloadUrl)
} catch (error) {
console.error('Download failed:', error)
} finally {
setDownloading(false)
}
}
return (
<button
onClick={handleDownload}
disabled={downloading}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
>
{downloading ? 'Downloading...' : children}
</button>
)
}File Preview
typescript
// components/FilePreview.tsx
'use client'
import { useState } from 'react'
interface FilePreviewProps {
url: string
type: 'image' | 'pdf' | 'video'
name: string
}
export default function FilePreview({ url, type, name }: FilePreviewProps) {
const [isOpen, setIsOpen] = useState(false)
const renderPreview = () => {
switch (type) {
case 'image':
return (
<img
src={url}
alt={name}
className="max-w-full max-h-full object-contain"
/>
)
case 'pdf':
return (
<iframe
src={url}
className="w-full h-full"
title={name}
/>
)
case 'video':
return (
<video
src={url}
controls
className="max-w-full max-h-full"
>
Your browser does not support video playback
</video>
)
default:
return <div>Unsupported file type</div>
}
}
return (
<>
<button
onClick={() => setIsOpen(true)}
className="text-blue-500 hover:underline"
>
Preview {name}
</button>
{isOpen && (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
<div className="relative w-full h-full max-w-4xl max-h-full p-4">
<button
onClick={() => setIsOpen(false)}
className="absolute top-4 right-4 text-white text-2xl z-10"
>
✕
</button>
<div className="w-full h-full flex items-center justify-center">
{renderPreview()}
</div>
</div>
</div>
)}
</>
)
}Asset Optimization
Image Compression Configuration
javascript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
formats: ['image/webp', 'image/avif'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
minimumCacheTTL: 60 * 60 * 24 * 365, // 1 year
},
}
module.exports = nextConfigLazy Loading
typescript
// components/LazyImage.tsx
'use client'
import Image from 'next/image'
import { useState, useRef, useEffect } from 'react'
interface LazyImageProps {
src: string
alt: string
width: number
height: number
placeholder?: string
}
export default function LazyImage({
src,
alt,
width,
height,
placeholder = '/placeholder.jpg'
}: LazyImageProps) {
const [isInView, setIsInView] = useState(false)
const imgRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true)
observer.disconnect()
}
},
{ threshold: 0.1 }
)
if (imgRef.current) {
observer.observe(imgRef.current)
}
return () => observer.disconnect()
}, [])
return (
<div ref={imgRef} style={{ width, height }}>
<Image
src={isInView ? src : placeholder}
alt={alt}
width={width}
height={height}
className={`transition-opacity duration-300 ${
isInView ? 'opacity-100' : 'opacity-50'
}`}
/>
</div>
)
}Resource Preloading
typescript
// components/ResourcePreloader.tsx
'use client'
import { useEffect } from 'react'
interface ResourcePreloaderProps {
images?: string[]
fonts?: string[]
}
export default function ResourcePreloader({ images = [], fonts = [] }: ResourcePreloaderProps) {
useEffect(() => {
// Preload images
images.forEach(src => {
const img = new Image()
img.src = src
})
// Preload fonts
fonts.forEach(src => {
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'font'
link.type = 'font/woff2'
link.crossOrigin = 'anonymous'
link.href = src
document.head.appendChild(link)
})
}, [images, fonts])
return null
}Static Asset CDN
CDN Configuration
javascript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
assetPrefix: process.env.NODE_ENV === 'production'
? 'https://cdn.example.com'
: '',
images: {
loader: 'custom',
loaderFile: './lib/imageLoader.js',
},
}
module.exports = nextConfigjavascript
// lib/imageLoader.js
export default function imageLoader({ src, width, quality }) {
const params = new URLSearchParams()
params.set('w', width.toString())
if (quality) {
params.set('q', quality.toString())
}
return `https://cdn.example.com${src}?${params}`
}Environment Variable Configuration
bash
# .env.local
NEXT_PUBLIC_CDN_URL=https://cdn.example.com
NEXT_PUBLIC_ASSET_PREFIX=https://assets.example.comtypescript
// lib/assets.ts
export function getAssetUrl(path: string): string {
const cdnUrl = process.env.NEXT_PUBLIC_CDN_URL
return cdnUrl ? `${cdnUrl}${path}` : path
}
// Usage example
import { getAssetUrl } from '@/lib/assets'
export default function MyComponent() {
return (
<img
src={getAssetUrl('/images/logo.png')}
alt="Logo"
/>
)
}Performance Monitoring
Resource Loading Monitoring
typescript
// hooks/useResourceMonitor.ts
'use client'
import { useEffect, useState } from 'react'
interface ResourceTiming {
name: string
duration: number
size?: number
}
export function useResourceMonitor() {
const [resources, setResources] = useState<ResourceTiming[]>([])
useEffect(() => {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries()
const resourceTimings = entries.map(entry => ({
name: entry.name,
duration: entry.duration,
size: (entry as any).transferSize,
}))
setResources(prev => [...prev, ...resourceTimings])
})
observer.observe({ entryTypes: ['resource'] })
return () => observer.disconnect()
}, [])
return resources
}Best Practices
1. Image Optimization
typescript
// ✅ Good practice
<Image
src="/hero.jpg"
alt="Hero Image"
width={800}
height={400}
priority // Above-the-fold image
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
// ❌ Avoid
<img src="/hero.jpg" alt="Hero Image" />2. Font Optimization
typescript
// ✅ Good practice
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Avoid font flash
preload: true,
})
// ❌ Avoid
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />3. Resource Organization
public/
├── images/
│ ├── common/ # Common images
│ ├── pages/ # Page-specific images
│ └── components/ # Component images
├── icons/
│ ├── ui/ # UI icons
│ └── social/ # Social icons
└── fonts/
├── display/ # Display fonts
└── body/ # Body fontsSummary
Key points for Next.js static asset management:
- Image component - Automatic image performance optimization
- Font optimization - Use next/font to avoid layout shift
- Resource organization - Logical directory structure
- Performance optimization - Lazy loading, preloading, CDN
- Monitoring and analytics - Track resource loading performance
By using these features effectively, you can significantly improve your application's loading speed and user experience.