Next.js course Β· Module 3: Styling and Visual Optimization

Managing Static Assets in the Public Directory in Next.js 16

12 min read Β·
In this lesson12

In Metropolis Quantum, each district has its own central resource base - a place where elements available to all residents are stored: maps, information brochures, visual identity elements, and interactive holoscreens with city plans. These resources are immutable and publicly available, ensuring consistency and continuity of experience throughout the city.

In Next.js applications, the public directory plays the role of this central base. This is where we place all static assets that should be available without processing by the Next.js build system. In this chapter, we will look at how to efficiently manage these assets so that our application is both performant and well-organized.

What is the Public Directory in Next.js 16?

The public directory is a special folder in a Next.js application that serves the following functions:

  1. Serves static files directly - files placed in public are served from the root domain directory
  2. Does not process content - files do not go through the build system (unlike resources imported directly in components)
  3. Provides immutable URLs - file names do not change through the build process (no hashes are added to names)

This directory is ideal for storing resources such as:

  • Logo images and icons
  • Favicon and manifest.json files
  • robots.txt and sitemap.xml files
  • Fonts
  • Documents (PDF, DOCX)
  • Multimedia resources (videos, sounds)
  • Static data (JSON, CSV)

Structure of the public Directory

A well-organized public directory makes resource management easier. Here is an example structure:

public/
β”œβ”€β”€ favicon.ico                  # Site icon
β”œβ”€β”€ favicon-16x16.png            # Alternative favicon sizes
β”œβ”€β”€ favicon-32x32.png
β”œβ”€β”€ apple-touch-icon.png         # Icon for Apple devices
β”œβ”€β”€ manifest.json                # Manifest PWA
β”œβ”€β”€ robots.txt                   # Instructions for bots
β”œβ”€β”€ sitemap.xml                  # Sitemap for search engines
β”œβ”€β”€ fonts/                       # Directory with fonts
β”‚   β”œβ”€β”€ QuantumSans-Regular.woff2
β”‚   └── QuantumSans-Bold.woff2
β”œβ”€β”€ images/                      # Directory with images
β”‚   β”œβ”€β”€ logo.png
β”‚   β”œβ”€β”€ hero-banner.jpg
β”‚   β”œβ”€β”€ icons/                   # Subdirectory with icons
β”‚   β”‚   β”œβ”€β”€ arrow-right.svg
β”‚   β”‚   └── search.svg
β”‚   └── backgrounds/             # Subdirectory with backgrounds
β”‚       β”œβ”€β”€ quantum-pattern.svg
β”‚       └── districts/           # Further subdirectories for organization
β”‚           β”œβ”€β”€ downtown.jpg
β”‚           └── quantum-district.jpg
β”œβ”€β”€ videos/                      # Directory with videos
β”‚   └── intro.mp4
└── data/                        # Directory with static data
    β”œβ”€β”€ districts.json
    └── services.json

Accessing Assets from the Public Directory

Accessing assets from the public directory is simple and intuitive. Remember that:

  1. Paths are relative to the root domain directory
  2. You must prefix file names with a forward slash (/)

In React Components

// Example of using an image from the public directory
export default function Logo() {
  return (
    <img
      src="/images/logo.png"
      alt="Metropolis Quantum Logo"
      width={200}
      height={50}
    />
  );
}

In CSS

/* In a CSS file */
.hero-section {
  background-image: url('/images/backgrounds/quantum-pattern.svg');
  background-repeat: repeat;
}

In Metadata

// app/layout.tsx
import { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Metropolis Quantum',
  description: 'Official portal of the city of the future',
  icons: {
    icon: [
      { url: '/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
      { url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
    ],
    apple: [
      { url: '/apple-touch-icon.png' },
    ],
  },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="pl">
      <body>{children}</body>
    </html>
  );
}

The Image Component and the Public Directory

Although Next.js provides an optimized Image component from the next/image package, sometimes we need to use images directly from the public directory. The Image component can be used with images from the public directory while retaining optimization benefits:

import Image from 'next/image';

export default function HeroBanner() {
  return (
    <div className="hero-banner">
      <Image
        src="/images/hero-banner.jpg"
        alt="Welcome to Metropolis Quantum"
        width={1200}
        height={600}
        preload
      />
    </div>
  );
}

When to Use Images from Public Instead of Imports?

  1. When the image URL must be known at build time:

    • Images in metadata (Open Graph, Twitter)
    • CSS backgrounds
  2. When the image is very large and rarely used:

    • Large graphics that appear only in specific contexts
  3. When you want a direct link to the file:

    • Downloadable resources
    • Resources shared with other applications

Handling Different File Types

SVG

SVG files can be placed in the public directory and used as regular images:

export default function Icon() {
  return (
    <img src="/images/icons/search.svg" alt="Search" width={24} height={24} />
  );
}

Alternatively, SVGs can be imported directly in code (not through the public directory), which allows manipulation of their properties:

import SearchIcon from '@/public/images/icons/search.svg';

export default function SearchButton() {
  return (
    <button className="search-button">
      <SearchIcon className="icon" width={24} height={24} />
      Search
    </button>
  );
}

Note: For the above example to work, you need additional configuration, e.g., using @svgr/webpack.

Fonts

As we saw in the previous chapter, font files can be stored in the public directory and used with next/font/local:

import localFont from 'next/font/local';

export const quantumSans = localFont({
  src: [
    {
      path: '../public/fonts/QuantumSans-Regular.woff2',
      weight: '400',
      style: 'normal',
    },
    {
      path: '../public/fonts/QuantumSans-Bold.woff2',
      weight: '700',
      style: 'normal',
    },
  ],
});

Video and Audio

Multimedia files can also be stored in the public directory:

export default function IntroVideo() {
  return (
    <video
      controls
      width="100%"
      poster="/images/video-poster.jpg"
    >
      <source src="/videos/intro.mp4" type="video/mp4" />
      Your browser does not support the video player.
    </video>
  );
}

JSON Files

JSON stored in public can be loaded using fetch:

// app/districts/page.tsx
export default async function DistrictsPage() {
  // Fetch data from a JSON file
  const response = await fetch('http://localhost:3000/data/districts.json');
  const districts = await response.json();

  return (
    <div>
      <h1>Metropolis Quantum Districts</h1>
      <ul>
        {districts.map((district) => (
          <li key={district.id}>{district.name}</li>
        ))}
      </ul>
    </div>
  );
}

Tip: In real applications, a better solution is often to import JSON data directly in code (e.g., import districts from '@/data/districts.json') or store it in a database.

Organization and Best Practices

1. Directory Structure

Maintain a logical directory structure that reflects the purpose of assets:

public/
β”œβ”€β”€ branding/       # Brand-related elements
β”œβ”€β”€ icons/          # System icons
β”œβ”€β”€ images/         # Photos and graphics
β”œβ”€β”€ videos/         # Video files
β”œβ”€β”€ fonts/          # Fonts
└── data/           # Static data (JSON, CSV)

2. Naming Conventions

Use consistent naming conventions:

  • Use kebab-case (hero-banner.jpg) instead of spaces (hero banner.jpg)
  • Add size suffixes for different variants (logo-sm.png, logo-lg.png)
  • Use prefixes for grouping (icon-home.svg, icon-user.svg)

3. Asset Optimization

Before placing assets in the public directory, optimize them:

  • Images: Compress using tools like TinyPNG, ImageOptim
  • SVG: Minimize using SVGO
  • Video: Compress to proper formats and sizes
  • Fonts: Use WOFF2 as the preferred format

4. Caching

Next.js 16 adds the Cache-Control: public, max-age=0 header to files in the public folder, because such files can change without changing their name. Long-lived, immutable caching applies only to files in /_next/static, which carry a content hash in their name. If you know your assets do not change, set your own headers in the next.config.js file:

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/fonts/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable',
          },
        ],
      },
      {
        source: '/images/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=86400, stale-while-revalidate=31536000',
          },
        ],
      },
    ];
  },
};

Problems and Solutions

Problem 1: Public Directory Too Large

If the public directory becomes too large, it can affect deployment time and resource management.

Solution:

  • Move rarely used, large assets to CDN (Content Delivery Network) services
  • Apply lazy loading strategies for assets
  • Optimize all assets before adding them to the directory

Problem 2: Image URL Issues

Solution:

  • Always use forward slashes at the beginning of paths (/images/logo.png, not images/logo.png)
  • Avoid spaces and special characters in file names
  • Check letter case in file names (Linux systems are case-sensitive)

Problem 3: Inconsistency in Asset Management

Solution:

  • Create and follow naming conventions for the entire team
  • Document the public directory structure
  • Consider creating abstract components for frequently used assets

Practical Examples

Example 1: Icon System from the Public Directory

// components/Icon.tsx
type IconName = 'home' | 'search' | 'user' | 'settings' | 'menu';

interface IconProps {
  name: IconName;
  size?: 'sm' | 'md' | 'lg';
  color?: string;
  className?: string;
}

const sizeMap = {
  sm: { width: 16, height: 16 },
  md: { width: 24, height: 24 },
  lg: { width: 32, height: 32 },
};

export function Icon({
  name,
  size = 'md',
  color = 'currentColor',
  className = '',
}: IconProps) {
  const dimensions = sizeMap[size];

  return (
    <img
      src={`/icons/${name}.svg`}
      alt={`${name} icon`}
      className={`icon icon-${name} ${className}`}
      {...dimensions}
      style={{ filter: `${color !== 'currentColor' ? `color(${color})` : ''}` }}
    />
  );
}

Usage:

import { Icon } from '@/components/Icon';

export default function Navbar() {
  return (
    <nav className="navbar">
      <Icon name="home" size="sm" />
      <Icon name="search" color="#3a86ff" />
      <Icon name="user" className="user-icon" />
    </nav>
  );
}

Example 2: Dynamic Photo Loading System from the Public Directory

// components/DistrictImage.tsx
import Image from 'next/image';

interface DistrictImageProps {
  districtId: string;
  alt: string;
  className?: string;
}

export function DistrictImage({ districtId, alt, className = '' }: DistrictImageProps) {
  return (
    <div className={`district-image-container ${className}`}>
      <Image
        src={`/images/backgrounds/districts/${districtId}.jpg`}
        alt={alt}
        fill
        className="district-image"
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
      />
    </div>
  );
}

Usage:

import { DistrictImage } from '@/components/DistrictImage';

export default function DistrictCard({ district }) {
  return (
    <div className="district-card">
      <DistrictImage
        districtId={district.id}
        alt={`District ${district.name}`}
      />
      <h3>{district.name}</h3>
      <p>{district.description}</p>
    </div>
  );
}

Example 3: SEO Configuration with Public Assets

// app/districts/[id]/page.tsx
import { Metadata } from 'next';

interface Props {
  params: Promise<{ id: string }>;
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const districtId = (await params).id;
  const district = await fetchDistrictData(districtId);

  return {
    title: `${district.name} | Metropolis Quantum`,
    description: district.description,
    openGraph: {
      images: [{
        url: `/images/backgrounds/districts/${districtId}.jpg`,
        width: 1200,
        height: 630,
        alt: district.name,
      }],
      type: 'website',
    },
    twitter: {
      card: 'summary_large_image',
      images: [`/images/backgrounds/districts/${districtId}.jpg`],
    },
  };
}

export default async function DistrictPage({ params }: Props) {
  const district = await fetchDistrictData((await params).id);

  return (
    <div className="district-page">
      <h1>{district.name}</h1>
      {/* Rest of page content */}
    </div>
  );
}

// Function fetching district data
async function fetchDistrictData(id: string) {
  // In a real application: fetching data from an API or database
  const response = await fetch(`http://localhost:3000/data/districts/${id}.json`);
  return response.json();
}

CDN Integration

For larger applications, it is worth considering moving assets from the public directory to an external CDN (Content Delivery Network), which will ensure faster loading worldwide.

Base Path Configuration in Next.js

// next.config.js
module.exports = {
  assetPrefix: process.env.NODE_ENV === 'production' ? 'https://cdn.twojadomena.com' : '',
};

Component for Managing Asset Paths

// utils/assetPath.ts
export function getAssetPath(path: string): string {
  const cdnUrl = process.env.NEXT_PUBLIC_CDN_URL || '';
  return `${cdnUrl}${path}`;
}

Usage:

import { getAssetPath } from '@/utils/assetPath';

export default function Logo() {
  return (
    <img
      src={getAssetPath('/images/logo.png')}
      alt="Metropolis Quantum Logo"
      width={200}
      height={50}
    />
  );
}

Advanced Techniques

1. Dynamic Asset Imports

You can dynamically import assets from the public directory during build:

// utils/getDistrictData.ts
import fs from 'fs';
import path from 'path';

export function getDistrictData() {
  const filePath = path.join(process.cwd(), 'public', 'data', 'districts.json');
  const fileContents = fs.readFileSync(filePath, 'utf8');
  return JSON.parse(fileContents);
}

2. Generating Assets During Build

You can generate static assets during application build:

// scripts/generate-sitemap.js
const fs = require('fs');
const path = require('path');

// Function generating sitemap.xml
function generateSitemap(routes) {
  const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    ${routes.map(route => `
      <url>
        <loc>https://metropolisquantum.pl${route}</loc>
        <lastmod>${new Date().toISOString()}</lastmod>
      </url>
    `).join('')}
  </urlset>
  `;

  fs.writeFileSync(
    path.join(process.cwd(), 'public', 'sitemap.xml'),
    sitemap
  );
}

// List of application routes
const routes = [
  '/',
  '/districts',
  '/about',
  '/contact',
  // other routes
];

generateSitemap(routes);

Then you can add this script to the build process in package.json:

{
  "scripts": {
    "build": "node scripts/generate-sitemap.js && next build"
  }
}

Security and Notes About Public Assets

1. Avoid Storing Sensitive Information

The public directory is publicly accessible, so you should never store in it:

  • API keys
  • Sensitive data
  • Configuration files with sensitive information

You can protect your assets from hotlinking (being used on other sites) using HTTP headers:

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/images/:path*',
        headers: [
          {
            key: 'Referrer-Policy',
            value: 'same-origin',
          },
        ],
      },
    ];
  },
};

3. Content Security Policy (CSP)

It is worth configuring CSP to protect your application:

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: "default-src 'self'; img-src 'self' https://cdn.twojadomena.com; font-src 'self' https://fonts.gstatic.com",
          },
        ],
      },
    ];
  },
};

Summary

The public directory in Next.js 16 is a powerful tool for managing static assets in the application. Proper organization, naming conventions, and asset optimization can significantly improve performance and project maintainability.

Key points:

  1. Structure and organization - maintain a logical directory structure and consistent naming conventions
  2. Optimization - compress and optimize assets before placing them in the directory
  3. Abstraction - create abstract components for managing access to assets
  4. Security - remember that the public directory content is publicly accessible
  5. Scaling - for larger applications consider using a CDN

Just like in the central resource base of Metropolis Quantum, a well-managed public directory ensures efficient access to all necessary elements, maintaining order and performance of the entire system.

In the next chapter, we will look at icons, favicon, and visual metadata, which are key elements of your Next.js 16 application's visual identity.

Code for this lesson: app/page.tsx
// CSS Performance Optimization - Cyberpunk Edition
'use client';

export default function PerformanceDemo() {
  return (
    <div style={{
      minHeight: '100vh',
      background: 'linear-gradient(135deg, #0f0f23, #1a1a2e)',
      color: '#ffffff',
      padding: '3rem 2rem'
    }}>
      <h1 style={{
        fontSize: '3rem',
        textAlign: 'center',
        background: 'linear-gradient(45deg, #64ffda, #7c4dff)',
        WebkitBackgroundClip: 'text',
        WebkitTextFillColor: 'transparent',
        marginBottom: '3rem'
      }}>
        CSS Performance Optimization
      </h1>

      {/* Will-change for animated elements */}
      <section style={{
        maxWidth: '1200px',
        margin: '0 auto 3rem',
        background: 'rgba(255,255,255,0.05)',
        padding: '2rem',
        borderRadius: '1rem',
        border: '1px solid rgba(100,255,218,0.2)'
      }}>
        <h2 style={{color: '#64ffda', marginBottom: '1.5rem'}}>1. will-change Property</h2>
        <div style={{
          width: '200px',
          height: '200px',
          background: 'linear-gradient(135deg, #64ffda, #7c4dff)',
          borderRadius: '1rem',
          willChange: 'transform',
          animation: 'float 3s ease-in-out infinite'
        }}></div>
        <p style={{color: '#b0bec5', marginTop: '1rem'}}>
          This element uses <code style={{background: 'rgba(0,0,0,0.5)', padding: '0.25rem 0.5rem', borderRadius: '0.25rem', color: '#64ffda'}}>will-change: transform</code> for better animation performance
        </p>
      </section>

      {/* Transform instead of left/top */}
      <section style={{
        maxWidth: '1200px',
        margin: '0 auto 3rem',
        background: 'rgba(255,255,255,0.05)',
        padding: '2rem',
        borderRadius: '1rem',
        border: '1px solid rgba(100,255,218,0.2)'
      }}>
        <h2 style={{color: '#64ffda', marginBottom: '1.5rem'}}>2. Transform vs Position</h2>
        <div style={{display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '2rem'}}>
          <div>
            <h3 style={{color: '#7c4dff', marginBottom: '1rem'}}>Slow (position)</h3>
            <div style={{
              position: 'relative',
              width: '150px',
              height: '150px',
              background: '#ff0080',
              borderRadius: '0.75rem'
            }}>
              <div style={{fontSize: '0.875rem', padding: '1rem', color: '#fff'}}>
                Uses position: relative; top/left
              </div>
            </div>
          </div>

          <div>
            <h3 style={{color: '#7c4dff', marginBottom: '1rem'}}>Fast (transform)</h3>
            <div style={{
              width: '150px',
              height: '150px',
              background: '#00ff7f',
              borderRadius: '0.75rem',
              transform: 'translateZ(0)'
            }}>
              <div style={{fontSize: '0.875rem', padding: '1rem', color: '#0f0f23', fontWeight: 600}}>
                Uses transform: translate()
              </div>
            </div>
          </div>
        </div>
      </section>

      {/* Contain property */}
      <section style={{
        maxWidth: '1200px',
        margin: '0 auto 3rem',
        background: 'rgba(255,255,255,0.05)',
        padding: '2rem',
        borderRadius: '1rem',
        border: '1px solid rgba(100,255,218,0.2)'
      }}>
        <h2 style={{color: '#64ffda', marginBottom: '1.5rem'}}>3. CSS Containment</h2>
        <div style={{
          contain: 'layout style paint',
          background: 'rgba(124,77,255,0.2)',
          padding: '1.5rem',
          borderRadius: '0.75rem',
          border: '1px solid rgba(124,77,255,0.5)'
        }}>
          <p style={{color: '#b0bec5', lineHeight: 1.6}}>
            This container uses <code style={{background: 'rgba(0,0,0,0.5)', padding: '0.25rem 0.5rem', borderRadius: '0.25rem', color: '#7c4dff'}}>contain: layout style paint</code>
            <br/><br/>
            Isolates layout changes to this container only, improving rendering performance.
          </p>
        </div>
      </section>

      {/* Content-visibility */}
      <section style={{
        maxWidth: '1200px',
        margin: '0 auto 3rem',
        background: 'rgba(255,255,255,0.05)',
        padding: '2rem',
        borderRadius: '1rem',
        border: '1px solid rgba(100,255,218,0.2)'
      }}>
        <h2 style={{color: '#64ffda', marginBottom: '1.5rem'}}>4. content-visibility</h2>
        {[1, 2, 3, 4, 5].map(i => (
          <div key={i} style={{
            contentVisibility: 'auto',
            containIntrinsicSize: '0 200px',
            marginBottom: '1.5rem',
            background: 'rgba(100,255,218,0.1)',
            padding: '1.5rem',
            borderRadius: '0.75rem',
            border: '1px solid rgba(100,255,218,0.3)'
          }}>
            <h3 style={{color: '#64ffda', marginBottom: '0.75rem'}}>Section {i}</h3>
            <p style={{color: '#b0bec5', lineHeight: 1.6}}>
              This section uses content-visibility: auto. The browser renders it only when in the viewport,
              which significantly improves performance with large content lists.
            </p>
          </div>
        ))}
      </section>

      {/* Critical CSS */}
      <section style={{
        maxWidth: '1200px',
        margin: '0 auto 3rem',
        background: 'rgba(255,255,255,0.05)',
        padding: '2rem',
        borderRadius: '1rem',
        border: '1px solid rgba(100,255,218,0.2)'
      }}>
        <h2 style={{color: '#64ffda', marginBottom: '1.5rem'}}>5. Critical CSS Best Practices</h2>
        <ul style={{color: '#b0bec5', lineHeight: 2, paddingLeft: '1.5rem'}}>
          <li>Inline critical styles in &lt;head&gt;</li>
          <li>Lazy-load non-critical CSS</li>
          <li>Use CSS-in-JS for components</li>
          <li>Minify and compress CSS</li>
          <li>Remove unused styles (PurgeCSS)</li>
          <li>Use CDN for faster delivery</li>
        </ul>
      </section>

      <style jsx global>{`
        @keyframes float {
          0%, 100% { transform: translateY(0); }
          50% { transform: translateY(-20px); }
        }
      `}</style>
    </div>
  );
}

Docs entries used in this lesson

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. CSS Custom Properties (variables) are defined using:

  2. 2. SASS/SCSS is:

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Create CSS with accessibility in mind: focus styles, prefers-reduced-motion, high contrast

  • Click in order

    Arrange the CSS Grid template declaration:

  • Vertical ordering

    Arrange the stages of image optimization by the next/image component in the correct order:

Useful articles