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.pdf

Accessing Static Assets

// 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

// 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

// 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 = nextConfig
// 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

// 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>
  )
}
// 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

// 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

// 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

// 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>
  )
}
/* 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

// 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

// 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

// 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

// 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

// 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

// 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 = nextConfig

Lazy Loading

// 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

// 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

// 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 = nextConfig
// 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

# .env.local
NEXT_PUBLIC_CDN_URL=https://cdn.example.com
NEXT_PUBLIC_ASSET_PREFIX=https://assets.example.com
// 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

// 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

// ✅ 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

// ✅ 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 fonts

Summary

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.