Skip to content

Grav Media Handling — Image Manipulation, Thumbnails and Responsive Images

DodaTech Updated 2026-06-27 7 min read

In this tutorial, you'll learn Grav media handling — image manipulation with built-in actions, thumbnail generation, media aliases, responsive images, Lazy Loading, and optimizing images for performance.

What You'll Learn

  • How Grav handles media files (images, videos, documents)
  • Image manipulation: resize, crop, rotate, flip
  • Thumbnail generation and image styles
  • Media aliases for reusable image references
  • Responsive images with srcset
  • Lazy loading and performance optimization

Why It Matters

In WordPress, image handling requires media settings and plugins for optimization. In Grav, images are handled through the media manager and Twig filters. You can resize, crop, and manipulate images directly in templates without any server-side processing setup. Images are stored alongside page content, making organization intuitive. Grav's image actions are chainable — you can resize, then crop, then convert to WebP in a single template call.

Real-World Use

A photography portfolio site needs thumbnails for the gallery page (300x200), medium images for the listing page (800x600), and full-resolution images for the detail page. Using Grav's image actions in Twig, the template resizes images at render time. The original high-res files are stored once. Different templates request different sizes. No batch processing, no cropping scripts, no duplicated files.

Learning Path

flowchart LR
    A["User Management"] --> B["Media Handling
← You are here"]:::current B --> C["Grav API"] C --> D["Web Services"] D --> E["E-commerce with Grav"] E --> F["Caching Deep Dive"] F --> G["Performance Optimization"] classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px

How Grav Stores Media

Media files are stored in the page folder alongside the Markdown file:

user/pages/02.about/
├── default.md
├── team.jpg              # Page media
├── office.jpg            # Page media
└── logo.svg              # Page media

Grav automatically detects media files in page folders and makes them available through page.media.

Accessing Media in Templates

Listing All Media

{% for image in page.media.images %}
    <img src="{{ image.url }}" alt="{{ image.title }}" />
{% endfor %}

Accessing a Specific Image

<img src="{{ page.media['team.jpg'].url }}" alt="Our Team" />

Media Types

{% for image in page.media.images %}      {# Only images #}
{% for video in page.media.videos %}       {# Only videos #}
{% for file in page.media.files %}         {# All files #}
{% for audio in page.media.audio %}        {# Only audio #}
{% for document in page.media.documents %} {# Only documents #}

Image Manipulation Actions

Grav provides chainable image actions:

Resize

<img src="{{ page.media['photo.jpg'].resize(400, 300).url }}" alt="" />

Crop

<img src="{{ page.media['photo.jpg'].cropResize(400, 300).url }}" alt="" />

Crop from Position

{# Crop from center #}
<img src="{{ page.media['photo.jpg'].crop(300, 300, 'center').url }}" alt="" />

{# Crop positions: top-left, top, top-right, left, center, right, bottom-left, bottom, bottom-right #}

Rotate

<img src="{{ page.media['photo.jpg'].rotate(90).url }}" alt="" />   {# 90° clockwise #}
<img src="{{ page.media['photo.jpg'].rotate(-90).url }}" alt="" />  {# 90° counter-clockwise #}
<img src="{{ page.media['photo.jpg'].flip('x').url }}" alt="" />    {# Horizontal flip #}
<img src="{{ page.media['photo.jpg'].flip('y').url }}" alt="" />    {# Vertical flip #}
<img src="{{ page.media['photo.jpg'].flip('xy').url }}" alt="" />   {# Both #}

Chaining Actions

<img src="{{ page.media['photo.jpg']
    .resize(800, 600)
    .cropResize(400, 300)
    .rotate(90)
    .url }}" alt="" />

Quality and Format

{# Set JPEG quality #}
<img src="{{ page.media['photo.jpg'].quality(80).url }}" alt="" />

{# Convert format #}
<img src="{{ page.media['photo.jpg'].format('webp').url }}" alt="" />

{# Combined #}
<img src="{{ page.media['photo.jpg'].resize(800, 600).quality(85).format('webp').url }}" alt="" />

Thumbnails

Generate consistent thumbnails for listings:

{% for image in page.media.images %}
    <div class="gallery-item">
        <a href="{{ image.url }}">
            <img src="{{ image.cropResize(200, 200).url }}"
                 alt="{{ image.title }}"
                 loading="lazy" />
        </a>
    </div>
{% endfor %}

Responsive Images with srcset

{% set image = page.media['hero.jpg'] %}

{% set small = image.resize(480, 320) %}
{% set medium = image.resize(768, 512) %}
{% set large = image.resize(1200, 800) %}
{% set xlarge = image.resize(1920, 1280) %}

<img
    src="{{ small.url }}"
    srcset="{{ small.url }} 480w,
            {{ medium.url }} 768w,
            {{ large.url }} 1200w,
            {{ xlarge.url }} 1920w"
    sizes="(max-width: 480px) 100vw,
           (max-width: 768px) 100vw,
           (max-width: 1200px) 100vw,
           1920px"
    alt="{{ page.title }}"
    loading="lazy"
/>

Output:

<img
    src="/user/pages/01.home/hero.jpg"
    srcset="/user/pages/01.home/hero.jpg?resize=480,320 480w,
            /user/pages/01.home/hero.jpg?resize=768,512 768w,
            /user/pages/01.home/hero.jpg?resize=1200,800 1200w,
            /user/pages/01.home/hero.jpg?resize=1920,1280 1920w"
    sizes="(max-width: 480px) 100vw, (max-width: 768px) 100vw,
           (max-width: 1200px) 100vw, 1920px"
    alt="Hero Image"
    loading="lazy"
/>

Media Aliases

Register media paths as aliases in system.yaml:

media:
    uploads:
        type: images
        path: 'user://data/uploads'
    logos:
        type: images
        path: 'theme://images/logos'

Access aliased media:

<img src="{{ media['uploads://profile-photo.jpg'].resize(150, 150).url }}" alt="" />
<img src="{{ media['logos://dodatech.svg'].url }}" alt="DodaTech Logo" />

Media from Other Folders

Access media from any folder:

{# From the theme #}
<img src="{{ url('theme://images/logo.png') }}" alt="" />

{# From user/data #}
<img src="{{ url('user://data/uploads/file.pdf') }}" alt="" />

{# From a specific page #}
<img src="{{ grav.page.find('/about').media['team.jpg'].url }}" alt="" />

Lazy Loading

<img
    src="{{ image.cropResize(50, 50).url }}"  {# Tiny placeholder #}
    data-src="{{ image.resize(800, 600).url }}"  {# Full image #}
    class="lazy"
    alt="{{ image.title }}"
    loading="lazy"
/>

Learning Path

flowchart LR
    A["User Management"] --> B["Media Handling
← You are here"]:::current B --> C["Grav API"] C --> D["Web Services"] D --> E["E-commerce with Grav"] classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px

Common Mistakes

  1. Not using cropResize for uniform thumbnails: resize() keeps aspect ratio, resulting in uneven thumbnail sizes. Use cropResize() to create uniform thumbnails that fill the exact dimensions.

  2. Forgetting to account for non-image media: If you iterate page.media directly, it includes videos and documents. Use page.media.images to get only images.

  3. Chaining too many actions: Each action is a server-side image processing call. Chaining resize, crop, rotate, and format conversion on every page load can be slow. Cache the results.

  4. Not using responsive images: Sending a 1920px image to a mobile user wastes bandwidth. Use srcset and sizes attributes to serve appropriately sized images.

  5. Using high-quality settings unnecessarily: Quality 85 is visually identical to 95 for web images but saves 30-50% file size. Quality 70 is acceptable for thumbnails.

Practice Questions

  1. How do you resize an image to exactly 400x300 pixels in Grav? Answer: Use cropResize(400, 300) which crops the image to fill the exact dimensions, unlike resize() which maintains aspect ratio.

  2. How do you serve different image sizes for different screen widths? Answer: Use the srcset and sizes attributes. Generate multiple sizes with resize() in Twig and set them as the srcset with appropriate width descriptors.

  3. How do you convert an image to WebP format in a template? Answer: Chain the format('webp') action: {{ image.resize(800, 600).format('webp').url }}. The converted image is cached for subsequent requests.

  4. How do you access media from a different page? Answer: Use grav.page.find('/path').media['filename.jpg'] or the media stream alias: media['uploads://file.jpg'].

  5. Challenge: Build a responsive image gallery with 20+ images. The gallery page should show uniform thumbnails (300x200) using cropResize, the detail view should show medium images (1200x800) with quality 85 and WebP format, implement srcset for 3 breakpoints (mobile 480w, tablet 768w, desktop 1200w), add lazy loading to all images, create a lightbox modal for full-resolution viewing, optimize JPEG quality to balance file size and visual quality, and add alt text descriptions from the media metadata.

FAQ

Where does Grav cache manipulated images?

Cached images are stored in user/data/cache/images/. The cache is automatically managed — old cached versions are cleaned up when the source image changes.

Can I use SVG images with Grav's media actions?

SVG images are vector graphics and do not support raster actions like resize, crop, or format conversion. Use SVG directly without actions.

How do I set a default image if the page has no media?

Use a conditional with a default: {% set image = page.media.images|first ?: media['defaults://placeholder.jpg'] %}.

What image formats does Grav support for manipulation?

Grav supports JPEG, PNG, GIF, and WebP for image manipulation. The GD or Imagick PHP extension must be installed on the server.

How do I add ALT text to images?

Set the image's alt property: {{ image.cropResize(300, 200).url }}. The ALT text must be added separately in the <img> tag's alt attribute.

Mini Project

Goal: Build an optimized image-heavy portfolio page.

  1. Create a portfolio section with 30+ high-resolution images
  2. Create thumbnails (uniform 300x200) using cropResize
  3. Create medium images (800x600) for listing view
  4. Implement srcset with 3 breakpoints
  5. Convert all images to WebP with quality 85
  6. Add lazy loading with a small blur-up placeholder
  7. Create a lightbox viewer for full-resolution images
  8. Compare file sizes: original vs WebP vs JPEG at different qualities
  9. Measure page load time with and without responsive images
  10. Document the file size savings from WebP conversion and responsive sizing

What's Next

Now you can handle media efficiently. Next, learn the Grav API:

Continue to Lesson 32: Grav APIREST API, JSON responses, and external integrations.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro