Nette Assets: Finally unified API for everything from images to Vite

yesterday by David Grudl  

Introducing Nette Assets – a library that completely transforms working with static files in Nette applications. Tested and proven on dozens of projects.

How many times have you found yourself writing code like this?

<link rel="stylesheet" href="{$baseUrl}/css/style.css?v=7">

<img src="{$baseUrl}/images/logo.png?v=1699123456" width="200" height="100" alt="Logo">

You've been manually constructing URLs, adding versioning parameters, determining and adding correct image dimensions and so on? Those days are over.

Through years of developing web applications, I was looking for a solution that would be very simple to use, yet infinitely open for extension. Something I could use across all my websites – from simple presentations to complex e-shops. After testing various approaches and validating numerous principles on real projects, Nette Assets was eventually born.

With Nette Assets, the same code changes to:

{asset 'css/style.css'}

<img n:asset="images/logo.png" alt="Logo">

And that's it! The library automatically:

✅ Detects image dimensions and inserts them into HTML
✅ Adds versioning parameters based on file modification time
✅ Has native Vite support with Hot Module Replacement

No configuration!

Let's start with the simplest scenario: you have a www/assets/ folder full of images, CSS and JS files. You want to include them in pages with proper versioning and automatic dimensions. Then you don't need to configure anything at all, just install Nette Assets:

composer require nette/assets

… and you can start using all the new Latte tags and they will work immediately:

{asset 'css/style.css'}
<img n:asset="images/logo.png">

I mainly use the {asset} tag for CSS and scripts, while preferring n:asset for images since I like having HTML elements like <img> explicitly visible in templates – but it's a matter of personal preference.

This generates code like:

<link rel="stylesheet" href="https://example.cz/assets/css/style.css?v=1670133401">
<img src="https://example.cz/assets/images/logo.png?v=1699123456" width="200" height="100">

Why the versioning? Browsers cache static files and when you change a file, the browser may still use the old version. So-called cache busting solves this problem by adding a parameter like ?v=1699123456, which changes with each file modification and forces the browser to download a new version. Versioning can be disabled in configuration.

Let's add some configuration

Do you prefer a different folder name than assets? In that case we need to add configuration to common.neon, but just three lines are enough:

assets:
	mapping:
		default: static  # files in /www/static/

Sometimes I put assets on a separate subdomain, like here on the Nette website. The configuration then looks like this:

assets:
	mapping:
		default:
			path: %rootDir%/www.files     # files in /www.files/
			url: https://files.nette.org

On the blog I wanted to keep things organized and divide assets logically into three categories: website design (logo, icons, styles), images in articles and recorded versions of articles. Each category has its own folder:

assets:
	mapping:
		default: assets       # regular files /www/assets/
		content: media/images # article images in /www/media/images/
		voice: media/audio    # audio files in /www/media/audio/

To access different categories we use colon notation category:file. If you don't specify a category, the default (default) is used. So in the template I have:

{* Article images *}
<img n:asset="content:hero-photo.jpg" alt="Hero image">

{* Audio version of article *}
<audio n:asset="voice:123.mp3" controls></audio>

In real templates we naturally work with dynamic data. You usually have an article in a variable, say $post, so I can't write voice:123.mp3, I simply write:

<audio n:asset="voice:{$post->id}.mp3" controls></audio>

To avoid worrying about audio formats in templates, I can add automatic extension completion to the configuration:

assets:
	mapping:
		voice:
			path: media/audio
			extension: mp3  # automatically adds .mp3

Then just write:

<audio n:asset="voice:{$post->id}" controls></audio>

When I later start using a better M4A format, I'll just need to modify the configuration. And since it doesn't make sense to convert old recordings, I'll specify an array of possible extensions – the system will try them in the given order and use the first one for which a file exists:

assets:
	mapping:
		audio:
			path: media/audio
			extension: [m4a, mp3]  # tries M4A first, then MP3

What if the file simply doesn't exist?

Not every article has a recorded version. I need to verify if the file exists at all, and if not, not render the <audio> element.

This is solved with a single character! Question mark in the attribute name n:asset?

{* Shows audio player only if recorded version exists *}
<audio n:asset?="voice:{$post->id}" controls></audio>

Similarly there's a question mark variant of the {asset?} tag. And if you need to wrap an asset in your own HTML structure or add conditional rendering logic, you can use the asset() function or its “question mark” variant tryAsset():

{var $voice = tryAsset("voice:{$post->id}")}
<div n:if="$voice" class="voice-version">
	<p>🎧 You can also listen to this article</p>
	<audio n:asset=$voice controls></audio>
</div>

The tryAsset() function returns an asset object or null if the file doesn't exist. The asset() function always returns an asset object or throws an exception. This enables various patterns, useful is for example this fallback trick when you want to show a replacement image:

{var $avatar = tryAsset("avatar:{$user->id}") ?? asset('avatar:default')}
<img n:asset=$avatar alt="Avatar">

And what objects do these functions actually return? Let's explore these objects.

Working with assets in PHP code

You work with assets not only in templates, but also in PHP code. The Registry class serves to access them, which is the main interface of the entire library. We have this service passed via dependency injection:

class ArticlePresenter extends Presenter
{
	public function __construct(
		private Nette\Assets\Registry $assets
	) {}
}

Registry provides getAsset() and tryGetAsset() methods for getting asset objects. Each object represents some resource. It doesn't have to be a physical file – it can be dynamically generated. Asset carries information about URL and metadata. In the Nette\Assets namespace you'll find these classes:

  • ImageAsset – images (width, height)
  • ScriptAsset – JavaScript files (type for modules)
  • StyleAsset – CSS files (media queries)
  • AudioAsset / VideoAsset – media (duration, dimensions)
  • FontAsset – fonts (proper CORS headers)
  • EntryAsset – entry points for Vite
  • GenericAsset – other files (basic URL and metadata)

Each asset has readonly properties specific to its type:

// ImageAsset for images
$image = $this->assets->getAsset('photo.jpg');
echo $image->url;      // '/assets/photo.jpg?v=1699123456'
echo $image->width;    // 1920
echo $image->height;   // 1080
echo $image->mimeType; // 'image/jpeg'

// AudioAsset for audio files
$audio = $this->assets->getAsset('song.mp3');
echo $audio->duration;  // duration in seconds

// ScriptAsset for JavaScript
$script = $this->assets->getAsset('app.js');
echo $script->type;     // 'module' or null

Properties like image dimensions or audio file duration are loaded lazily (only when you first use them), so the system stays fast even with thousands of files.

First-class Vite integration

Nette Assets have native support for Vite. This modern tool handles the need to compile (build) frontend code, because:

  • it's written in other languages (TypeScript, Sass)
  • it's transpiled for older browsers
  • it's bundled into one file
  • it's minified for smaller size

To avoid repeating this entire process with every change in development mode, Vite serves files directly without bundling and minification. Every change is thus reflected immediately. And as a bonus it can even do it without page refresh – you edit CSS or JavaScript and the change is reflected in the browser within milliseconds, without losing form data or application state. This is called Hot Module Replacement.

For production, Vite then creates classic optimized builds – bundles, minifies, splits into chunks and adds versioned names for cache busting.

In today's JavaScript ecosystem, Vite is the de facto standard for modern frontend development. That's why it made sense to include support directly in Nette Assets. Thanks to the open architecture, you can easily write a mapper for any other bundler.

You might wonder: “Vite adapters for Nette already exist, what's different about this?” Yes, they exist, and I thank their authors for that. The difference is that Nette Assets is a complete system for managing all static files, and Vite is one component, but very elegantly integrated.

Just add two words to the common.neon configuration: type: vite.

assets:
	mapping:
		default:
			type: vite
			path: assets

Vite is known for working even without configuration or being very easy to configure. However, backend integration adds complexity. That's why I created @nette/vite-plugin, which makes the whole integration simple again.

In the vite.config.ts configuration file, just activate the plugin and specify the path to entry points:

import { defineConfig } from 'vite';
import nette from '@nette/vite-plugin';

export default defineConfig({
	plugins: [
		nette({
			entry: 'app.js',  // entry point
		}),
	],
});

And in the layout template we load the entry point:

<!doctype html>
<head>
	{asset 'app.js'}
</head>

And that's it! The system automatically takes care of the rest:

  • In development mode loads files from Vite dev server with Hot Module Replacement
  • In production uses compiled, optimized files

No configuration, no conditions in templates. It just works.

Power of custom mappers: Real examples

mapper is a component that knows where to find files and how to create URLs for loading them. You can have multiple mappers for different purposes – local files, CDN, cloud storage or build tools. The Nette Assets system is completely open for creating custom mappers for anything.

Mapper for product images

On an e-shop you need product images in different sizes. You usually already have a service that handles resize and image optimization. Simply wrap it with a Nette Assets mapper.

The getAsset() method of your mapper must return the appropriate asset type. In our case ImageAsset. If we additionally give the constructor a file parameter with the path to the local file, it automatically loads image dimensions (width, height) and MIME type. If the requested file doesn't exist, we throw an AssetNotFoundException exception:

class ProductImageMapper implements Mapper
{
	public function getAsset(string $reference, array $options = []): Asset
	{
		// $reference is product ID
		$product = $this->database->getProduct((int) $reference);
		if (!$product) {
			throw new Nette\Assets\AssetNotFoundException("Product $reference not found");
		}

		$size = $options['size'] ?? 'medium'; // small, medium, large

		// We leverage our existing image service
		return new Nette\Assets\ImageAsset(
			url: $this->imageService->getProductUrl($product, $size),
			file: $this->imageService->getProductFile($product, $size)
		);
	}
}

Registration in configuration:

assets:
	mapping:
		product: App\ProductImageMapper()

Usage in templates:

{* Different product sizes *}
<img n:asset="product:{$product->id}, size: small" alt="Product thumbnail">
<img n:asset="product:{$product->id}, size: large" alt="Product detail">

Automatic OG image generation

OG (Open Graph) images are displayed when sharing on social networks. Instead of manual creation, you can have the system generate them automatically based on content type.

In this case, the $reference parameter determines the image context. For static pages (e.g. homepage, about) pre-prepared files are used. For articles, the image is generated dynamically based on the article title. The mapper first checks cache – if the image already exists, it returns it. Otherwise it generates it and saves it to cache for next use:

class OgImageMapper implements Mapper
{
	public function getAsset(string $reference, array $options = []): Asset
	{
		// For articles we generate dynamically based on title
		if ($reference === 'article') {
			$title = $options['title'] ?? throw new LogicException('Missing option title for article');
			$filename = '/generated/' . md5("article-{$title}") . '.png';
			$path = $this->staticDir . $filename;
			if (!file_exists($path)) {
				file_put_contents($path, $this->ogGenerator->createArticleImage($title));
			}

		} else { // For static pages we use pre-prepared files
			$filename = "/{$reference}.png";
			$path = $this->staticDir . $filename;
			if (!file_exists($path)) {
				throw new Nette\Assets\AssetNotFoundException("Static OG image '$reference' not found");
			}
		}

		return new Nette\Assets\ImageAsset($this->baseUrl . $filename, file: $path);
	}
}

Then in the template we add to the header:

{* For static page *}
{var $ogImage = asset('og:homepage')}

{* For article with dynamic generation *}
{var $ogImage = asset('og:article', title: $article->title)}
<meta property="og:image" content={$ogImage}>
<meta property="og:image:width" content={$ogImage->width}>
<meta property="og:image:height" content={$ogImage->height}>

Thanks to this approach you have automatically generated OG images for every article, which are created only once and then cached for further use.

Nette Assets: more elegant asset management

Nette Assets represent an elegant system that takes care of almost everything automatically.

  • Eliminates boilerplate code – no more manual URL construction
  • Supports modern workflow – Vite, TypeScript, code splitting
  • Is flexible – custom mappers, various storage systems
  • Works immediately – no complex configuration

Complete description can be found in documentation and code on GitHub.

You'll never write <img src="/images/photo.jpg?v=123456"> manually again!

David Grudl A web developer since 1999 who now specializes in artificial intelligence. He's the creator of Nette Framework and libraries including Texy!, Tracy, and Latte. He hosts the Tech Guys podcast and covers AI developments on Uměligence. His blog La Trine earned a Magnesia Litera award nomination. He's dedicated to AI education and approaches technology with pragmatic optimism.