Skip to content

Grav Theme Assets — CSS/JS Pipeline, Merging and Minification

DodaTech Updated 2026-06-27 8 min read

In this tutorial, you'll learn Grav theme asset management — the CSS/JS pipeline, asset merging and minification, loading strategies (async, defer, inline), and performance optimization for production delivery.

What You'll Learn

  • Grav's asset manager: how it works and why it matters
  • Registering CSS and JS assets in templates and plugins
  • Asset merging and minification for production
  • Async and deferred script loading
  • Inline CSS and JS for critical resources
  • Pipeline priority and ordering strategies

Why It Matters

In WordPress, enqueuing scripts and styles uses PHP functions in functions.php. In Grav, the asset manager is a Twig-based pipeline. You register assets in templates, plugins, or theme files, and Grav handles merging, minification, and delivery. A well-configured asset pipeline can reduce page weight by 60-80%, improve load times, and increase Lighthouse scores. This is especially important for sites that use multiple plugins, each adding their own CSS and JS.

Real-World Use

A marketing site uses 12 plugins, each adding its own CSS and JS files. Without asset management, the page loads 24 separate files — 12 CSS and 12 JS — causing 24 HTTP requests. Using Grav's asset pipeline, all CSS is merged into one file and minified. All JS is merged into another and minified. The result: 2 HTTP requests instead of 24, and a page weight reduction from 400KB to 180KB.

Learning Path

flowchart LR
    A["Theme Configuration"] --> B["Theme Assets
← You are here"]:::current B --> C["Theme Inheritance"] C --> D["Theme Languages"] D --> E["Plugin Architecture"] E --> F["Plugin Events"] classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px

Asset Manager Overview

Grav's asset manager is a Twig-based system that collects, processes, and outputs CSS and JS assets. It supports:

  • Registration: Add assets from templates, plugins, and themes
  • Ordering: Priority-based loading order
  • Merging: Combine multiple files into one
  • Minification: Remove whitespace and comments
  • Pipeline: Buffer and inline assets for performance

Registering Assets in Templates

Basic CSS Registration

{% do assets.addCss('theme://css/styles.css') %}
{% do assets.addCss('theme://css/custom.css', 10) %} {# Priority 10 #}

Basic JS Registration

{% do assets.addJs('theme://js/main.js') %}
{% do assets.addJs('theme://js/vendor.js', { priority: 10 }) %}

Registration with Attributes

{% do assets.addCss('theme://css/print.css', {
    priority: 5,
    media: 'print',
    group: 'critical'
}) %}

{% do assets.addJs('theme://js/analytics.js', {
    priority: 1,
    loading: 'async',
    group: 'footer'
}) %}

Outputting Assets

<!DOCTYPE html>
<html>
<head>
    {% block stylesheets %}
        {% do assets.addCss('theme://css/styles.css') %}
        {{ assets.css() }}
    {% endblock %}
</head>
<body>
    {{ page.content|raw }}

    {% block javascripts %}
        {% do assets.addJs('theme://js/main.js') %}
        {% do assets.addJs('theme://js/vendor.js') %}
        {{ assets.js() }}
    {% endblock %}
</body>
</html>

Pipeline Configuration

Enable the asset pipeline in user/config/system.yaml:

system:
    assets:
        css_pipeline: true
        css_minify: true
        js_pipeline: true
        js_minify: true

What the Pipeline Does

When enabled, the asset manager:

  1. Collects all registered CSS/JS files
  2. Reads their contents
  3. Minifies them (removes whitespace, comments, shortens variable names)
  4. Merges them into a single file
  5. Caches the merged file
  6. Outputs a single <link> or <script> tag

Result:

<!-- Before pipeline: 12 separate files -->
<link rel="stylesheet" href="file1.css" />
<link rel="stylesheet" href="file2.css" />
<!-- ... 10 more ... -->

<!-- After pipeline: 1 merged file -->
<link rel="stylesheet" href="/assets/merged-abc123.css" />

Asset Groups

Group assets to output them in different locations:

{% do assets.addCss('theme://css/critical.css', { group: 'critical' }) %}
{% do assets.addCss('theme://css/styles.css', { group: 'head' }) %}
{% do assets.addJs('theme://js/analytics.js', { group: 'head' }) %}
{% do assets.addJs('theme://js/main.js', { group: 'footer' }) %}

{# Output in head #}
{{ assets.css('critical') }}
{{ assets.css('head') }}
{{ assets.js('head') }}

{# Output at end of body #}
{{ assets.js('footer') }}

Async and Defer Loading

{# Async - load in background, execute when ready #}
{% do assets.addJs('theme://js/analytics.js', {
    loading: 'async',
    group: 'footer'
}) %}

{# Defer - load in background, execute after HTML parsed #}
{% do assets.addJs('theme://js/main.js', {
    loading: 'defer',
    group: 'footer'
}) %}

{# Render output #}
{{ assets.js('footer') }}

Output:

<script src="/assets/analytics.js" async></script>
<script src="/assets/main.js" defer></script>

Inline Assets

Inline CSS

{% do assets.addInlineCss("
body { background: #f5f5f5; }
h1 { color: var(--primary); }
") %}

Inline JS

{% do assets.addInlineJs("
document.addEventListener('DOMContentLoaded', function() {
    console.log('Page loaded');
});
") %}

Critical CSS Pattern

Inline critical CSS in the head for fast first paint:

<head>
    <style>
    {% do assets.addInlineCss(critical_css) %}
    {{ assets.css('critical') }}
    </style>
    <link rel="preload" href="{{ url('theme://css/styles.css') }}" as="style" onload="this.onload=null;this.rel='stylesheet'">
    <noscript><link rel="stylesheet" href="{{ url('theme://css/styles.css') }}"></noscript>
</head>

Asset Priority

Control load order with priority values (lower loads first):

{% do assets.addCss('theme://css/reset.css', { priority: 1 }) %}     {# Load first #}
{% do assets.addCss('theme://css/styles.css', { priority: 5 }) %}     {# Load second #}
{% do assets.addCss('theme://css/custom.css', { priority: 10 }) %}    {# Load third #}

Priority range: 1 (earliest) to 10 (latest). Default is 5.

Registering Assets from Plugins

Plugins can register assets on events:

<?php
namespace Grav\Plugin;

use Grav\Common\Plugin;

class MyPlugin extends Plugin
{
    public static function getSubscribedEvents()
    {
        return [
            'onAssetsInitialized' => ['onAssetsInitialized', 0],
        ];
    }

    public function onAssetsInitialized()
    {
        $this->grav['assets']->addCss('plugin://myplugin/css/style.css');
        $this->grav['assets']->addJs('plugin://myplugin/js/script.js');
    }
}

Remote Assets

{% do assets.addCss('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap') %}
{% do assets.addJs('https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js', {
    loading: 'defer'
}) %}

Cache Busting

Grav automatically adds a version query string to pipeline assets:

<link rel="stylesheet" href="/assets/merged/styles-abc123.css?v=2.3.1" />

The version changes when you modify any source file, forcing browsers to download the new version.

Learning Path

flowchart LR
    A["Theme Configuration"] --> B["Theme Assets
← You are here"]:::current B --> C["Theme Inheritance"] C --> D["Theme Languages"] D --> E["Plugin Architecture"] classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px

Common Mistakes

  1. Enabling pipeline without testing: Merging assets can break the site if CSS or JS files are not compatible. Test with pipeline enabled on a staging environment first.

  2. Not using theme:// stream wrapper: Always use theme://css/style.css instead of relative paths. The stream wrapper ensures the correct theme directory is used, even with child themes.

  3. Forgetting to output assets: Registering assets with {% do assets.addCss() %} does not output them. You must call {{ assets.css() }} in the template for the assets to appear in the HTML.

  4. Mixing pipelined and non-pipelined assets: Once the pipeline is enabled, all assets are merged. If you need some assets excluded from the pipeline, use the pipeline: false attribute: {% do assets.addJs('theme://js/lazy.js', { pipeline: false }) %}.

  5. Not setting loading strategy for non-critical JS: All JavaScript blocks HTML Parsing by default. Use loading: 'defer' for all non-critical scripts and loading: 'async' for independent scripts.

Practice Questions

  1. What does css_pipeline: true do in Grav? Answer: It merges all CSS files into a single file and minifies it. This reduces HTTP requests from N files to 1, and reduces file size by 30-60%.

  2. How do you load a JavaScript file with the defer attribute? Answer: Use {% do assets.addJs('theme://js/main.js', { loading: 'defer' }) %}. This tells the browser to download the script in parallel and execute it after parsing the HTML.

  3. What is the difference between assets.addInlineCss() and assets.addCss()? Answer: addInlineCss() adds CSS content directly into a <style> tag in the HTML, without a separate file. addCss() adds a <link> tag pointing to an external CSS file.

  4. How do you control the order in which CSS files load? Answer: Use the priority parameter. Lower priority values load first. Default is 5. Range is 1 (earliest) to 10 (latest).

  5. Challenge: Build a complete asset loading strategy for a production Grav site. Configure pipeline, minification, and cache busting. Separate assets into critical (inline in head), render-blocking (pipelined in head), and non-critical (deferred in footer). Create a Twig template that loads Google Fonts with the correct preload strategy, loads legacy CSS without pipeline, loads analytics JS asynchronously, and loads main JS deferred. Measure page load with and without the pipeline to demonstrate the improvement.

FAQ

What is the 'theme://' stream wrapper?

It is a Grav stream that points to the active theme's directory. theme://css/style.css resolves to user/themes/mytheme/css/style.css. It works with child themes, automatically using the child theme directory.

Can I exclude a specific CSS file from the pipeline?

Yes. Set pipeline: false on the asset registration: {% do assets.addCss('theme://css/legacy.css', { pipeline: false }) %}. This outputs the file separately, before or after the pipelined assets.

How does Grav handle asset cache busting?

Grav generates a hash from the combined asset contents. When any source file changes, the hash changes, producing a new URL. Browsers download the new version because the URL is different.

Should I use the asset pipeline in development?

No. Disable pipeline and minification in development to debug individual files. Enable them only in production for performance. Use environment-specific configuration to toggle this.

Can I register assets conditionally (only on certain pages)?

Yes. Wrap asset registration in Twig conditionals: {% if page.template == 'blog' %}{% do assets.addCss('theme://css/blog.css') %}{% endif %}.

Mini Project

Goal: Build and optimize a complete asset loading system for a Grav theme.

  1. Create a CSS directory with 6 files: reset.css, variables.css, typography.css, layout.css, components.css, utilities.css
  2. Create a JS directory with 3 files: vendor.js (third-party libraries), app.js (main application), analytics.js (tracking)
  3. Register all assets with appropriate priority and groups
  4. Configure pipeline and minification in system.yaml
  5. Create a critical CSS file with above-the-fold styles and inline it
  6. Load non-critical styles with preload and loadCSS fallback
  7. Defer all JavaScript except critical inline scripts
  8. Create a production vs development asset configuration
  9. Test page load with and without pipeline (use browser DevTools network tab)
  10. Measure and document the performance improvement

What's Next

Now you can optimize assets for performance. Next, learn theme inheritance:

Continue to Lesson 22: Theme Inheritance — Child themes, overriding parent templates, and theme customization.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro