Home β€Ί Blog β€Ί Jekyll Static Files and Assets: How to Manage Images, CSS, and JS
Tutorial

Jekyll Static Files and Assets: How to Manage Images, CSS, and JS

How Jekyll handles static files and assets β€” the assets/ folder, referencing files in templates, image management, static_files, and optimising assets for production.

Jekyll Static Files and Assets: How to Manage Images, CSS, and JS

Jekyll processes some files (Markdown, Liquid templates, Sass) and copies others unchanged. Understanding which is which β€” and how to reference assets correctly in your templates β€” is fundamental to building Jekyll sites that work properly across all environments.

Processed files vs static files

Jekyll handles two categories of files:

Processed files β€” files with YAML front matter, or files in special directories (_posts/, _layouts/, _includes/, _sass/). Jekyll runs these through its build pipeline: Liquid templating, Markdown conversion, Sass compilation.

Static files β€” everything else. Jekyll copies them to _site/ unchanged. Images, fonts, JavaScript files, PDFs, videos β€” all copied as-is.

my-jekyll-site/
β”œβ”€β”€ _posts/           ← processed (has front matter)
β”œβ”€β”€ _sass/            ← processed (Sass compilation)
β”œβ”€β”€ assets/
β”‚   β”œβ”€β”€ images/       ← static (copied unchanged)
β”‚   β”œβ”€β”€ fonts/        ← static (copied unchanged)
β”‚   β”œβ”€β”€ js/           ← static (copied unchanged)
β”‚   └── css/
β”‚       └── main.scss ← processed (has front matter β†’ compiled to CSS)

The assets/ folder convention

While Jekyll has no hard requirement for folder naming, assets/ is the universal convention for static files. Most themes use:

assets/
β”œβ”€β”€ css/
β”‚   └── main.scss        ← entry point for Sass (has front matter)
β”œβ”€β”€ js/
β”‚   β”œβ”€β”€ main.js
β”‚   β”œβ”€β”€ bookmarks.js
β”‚   └── search.js
β”œβ”€β”€ images/
β”‚   β”œβ”€β”€ logo.png
β”‚   β”œβ”€β”€ social-card.png
β”‚   β”œβ”€β”€ favicon.ico
β”‚   └── blog/
β”‚       β”œβ”€β”€ post-cover.webp
β”‚       └── another-cover.webp
└── fonts/
    └── inter.woff2

Jekyll copies everything in assets/ to _site/assets/ during build. A file at assets/images/logo.png is served at https://example.com/assets/images/logo.png.

Referencing assets in templates

The relative_url filter

Always use relative_url when referencing assets in Liquid templates:


<link rel="stylesheet" href="{{ '/assets/css/main.css' | relative_url }}">
<script src="{{ '/assets/js/main.js' | relative_url }}" defer></script>
<img src="{{ '/assets/images/logo.png' | relative_url }}" alt="Logo">

relative_url prepends site.baseurl to the path. If your site has baseurl: "" (root domain), it changes nothing. If your site lives at a subdirectory (baseurl: "/my-project"), it correctly produces /my-project/assets/images/logo.png.

Without relative_url, assets break on sites with a non-empty baseurl.

The absolute_url filter

For Open Graph tags, sitemaps, or any place that needs a full URL:


<meta property="og:image" content="{{ '/assets/images/social-card.png' | absolute_url }}">

absolute_url prepends both site.url and site.baseurl.

In Markdown content

![Hero image](/assets/images/blog/hero.webp)

[Download PDF](/assets/files/guide.pdf)

Note: Markdown links do not go through relative_url. If you have a baseurl, use the HTML <img> tag inside your Markdown, or ensure your permalink structure accounts for the base URL.

In SCSS/CSS

Reference assets from CSS using relative paths from the CSS file’s location. Since assets/css/main.scss is in assets/css/, fonts and images are reached with ../:

@font-face {
  font-family: "Inter";
  src: url("../fonts/inter.woff2") format("woff2");
}

.hero {
  background-image: url("../images/hero-bg.webp");
}

Working with images

File formats in 2026

  • WebP β€” best default choice. 25–35% smaller than JPEG at equivalent quality. Supported by all modern browsers.
  • AVIF β€” even smaller than WebP but slower to encode. Good for images you generate offline.
  • JPEG β€” use for photographs when WebP is not an option.
  • PNG β€” use for images requiring transparency or exact colours (logos, icons).
  • SVG β€” use for logos, icons, and illustrations. Infinitely scalable, tiny file size.

Converting images to WebP

# Single file
cwebp -q 85 image.jpg -o image.webp

# Batch convert all JPEGs
find assets/images -name "*.jpg" -exec sh -c   'cwebp -q 85 "$1" -o "${1%.jpg}.webp"' _ {} \;

# Using ImageMagick
mogrify -format webp -quality 85 assets/images/blog/*.jpg

Responsive images with srcset

For images that appear at different sizes across screen widths:

<img
  src="/assets/images/hero-800.webp"
  srcset="
    /assets/images/hero-400.webp 400w,
    /assets/images/hero-800.webp 800w,
    /assets/images/hero-1200.webp 1200w
  "
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 800px"
  alt="Hero image"
  width="800"
  height="450"
  loading="lazy"
>

Generate multiple sizes during your build process (using Pillow, ImageMagick, or a Jekyll plugin like jekyll-responsive-image).

Always specify width and height


<img src="{{ post.image | relative_url }}" alt="{{ post.title }}" width="800" height="450">

Width and height attributes prevent Cumulative Layout Shift (CLS) β€” a Core Web Vitals metric β€” by letting the browser reserve the correct space before the image loads.

Lazy loading

<!-- Below-the-fold images β€” lazy load -->
<img src="..." alt="..." loading="lazy">

<!-- Above-the-fold images (hero, LCP element) β€” eager load -->
<img src="..." alt="..." loading="eager">

Use loading="lazy" on images that are not visible when the page loads. Never use it on your LCP (Largest Contentful Paint) element β€” the hero image or main post image.

The site.static_files variable

Jekyll provides a site.static_files variable that lists all static files (files copied without processing):


{% for file in site.static_files %}
  {{ file.path }}          β†’ /assets/images/logo.png
  {{ file.basename }}      β†’ logo
  {{ file.name }}          β†’ logo.png
  {{ file.extname }}       β†’ .png
  {{ file.modified_time }} β†’ last modified date
{% endfor %}

Filter by extension or path:


{% assign images = site.static_files | where_exp: "f", "f.extname == '.webp'" %}
{% for image in images %}
  <img src="{{ image.path | relative_url }}" alt="{{ image.basename }}">
{% endfor %}

This is useful for auto-generating image galleries from files in a folder.

JavaScript files

Jekyll copies JavaScript files unchanged β€” no bundling, no transpilation. For simple sites, this is fine:

assets/js/
β”œβ”€β”€ main.js         ← copied as-is
β”œβ”€β”€ bookmarks.js    ← copied as-is
└── search.js       ← copied as-is

Reference in your layout:


<script src="{{ '/assets/js/main.js' | relative_url }}" defer></script>

Using Liquid in JavaScript files

If you need Jekyll variables in JavaScript (e.g. site data), add front matter to the JS file:


---
---
// assets/js/search-data.js

var SITE_DATA = {
  url: "{{ site.url }}",
  posts: [
    {% for post in site.posts %}
    {
      title: {{ post.title | jsonify }},
      url: "{{ post.url | relative_url }}",
      excerpt: {{ post.excerpt | strip_html | truncatewords: 30 | jsonify }}
    }{% unless forloop.last %},{% endunless %}
    {% endfor %}
  ]
};

Jekyll processes this file through Liquid (because of the front matter) and outputs JavaScript with the values baked in at build time.

Minifying JavaScript

Jekyll does not minify JS. Options:

  1. Minify manually and commit the minified file
  2. Use a build step (esbuild, rollup, or webpack) before Jekyll runs
  3. Use a CDN for third-party libraries and serve your own JS unminified

For most Jekyll blogs, unminified JS is fine β€” the files are small and HTTP/2 handles multiple small requests efficiently.

Favicon files

Place favicon files in your project root or assets/ and reference in <head>:

assets/
β”œβ”€β”€ favicon.ico          ← legacy IE fallback
β”œβ”€β”€ favicon-16x16.png
β”œβ”€β”€ favicon-32x32.png
β”œβ”€β”€ apple-touch-icon.png  ← 180Γ—180px for iOS
└── site.webmanifest     ← PWA manifest

<!-- _includes/head.html -->
<link rel="icon" type="image/x-icon" href="{{ '/favicon.ico' | relative_url }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ '/assets/favicon-32x32.png' | relative_url }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ '/assets/favicon-16x16.png' | relative_url }}">
<link rel="apple-touch-icon" sizes="180x180" href="{{ '/assets/apple-touch-icon.png' | relative_url }}">
<link rel="manifest" href="{{ '/assets/site.webmanifest' | relative_url }}">

Excluding assets from the build

Use exclude: in _config.yml to prevent source files (like uncompressed originals) from appearing in _site/:

exclude:
  - assets/images/originals/   # source files, not served
  - assets/fonts/source/       # font source files
  - tools/                     # build scripts
  - "*.psd"                    # Photoshop files
  - "*.ai"                     # Illustrator files

Cache busting

Because browsers cache static assets aggressively, changing a CSS or JS file may not update for returning visitors. Options:

Query string versioning β€” append a version number:


<link rel="stylesheet" href="{{ '/assets/css/main.css' | relative_url }}?v={{ site.version }}">

Update version: "1.2.3" in _config.yml after significant changes.

Filename hashing β€” include a hash in the filename (main.abc123.css). Requires a build pipeline; not supported natively by Jekyll.

Long cache headers with immutable assets β€” in _headers (Cloudflare/Netlify):

/assets/*
  Cache-Control: public, max-age=31536000, immutable

Use very long cache on assets with versioned filenames; use no-cache on HTML.

Summary: what goes where

File type Location How Jekyll handles it
Blog posts _posts/ Processed (Markdown β†’ HTML)
Page Markdown _pages/ or root Processed (Markdown β†’ HTML)
Layout HTML _layouts/ Used as template, not output
Include fragments _includes/ Used as template, not output
Sass partials _sass/ Compiled (via entry point)
Sass entry point assets/css/ Compiled to CSS
JavaScript assets/js/ Copied unchanged
Images assets/images/ Copied unchanged
Fonts assets/fonts/ Copied unchanged
Data files _data/ Loaded as site.data.*, not output

Understanding this table β€” what Jekyll processes vs what it copies β€” explains nearly every β€œwhy is my file not showing up” or β€œwhy is my template not working” issue you will encounter.

Organising your assets folder for maintainability

A well-organised assets/ directory makes finding and updating files fast. The conventional structure is: assets/css/ for compiled stylesheets, assets/js/ for JavaScript files, assets/images/ for site-wide images, assets/fonts/ for self-hosted typefaces, and assets/icons/ for SVG icons or favicon variants. Post-specific images often go in a post-images subfolder organised by year or post slug to prevent the root images directory from becoming unmanageable.

Naming conventions matter more as the asset library grows. Use lowercase hyphenated names for all files (hero-background.webp rather than HeroBackground.WebP). Include dimensions in image filenames when you serve multiple sizes (logo-200w.webp, logo-400w.webp). Prefix JavaScript files that are page-specific rather than sitewide (post.js, home.js, theme-detail.js) to distinguish them from libraries. These conventions make the assets directory self-documenting and reduce the time spent hunting for a specific file.

Version your asset files explicitly when they change significantly. Jekyll does not have a built-in asset fingerprinting system (unlike Webpack or Vite), so browser caching can cause visitors to see stale CSS or JavaScript after an update. The simplest workaround is a version query string in _config.yml:

asset_version: "2025-12"

Reference it in your layout: main.css?v={{ site.asset_version }}. When you update CSS significantly, bump the version string and the new filename breaks the browser cache for all visitors. This is not as elegant as hash-based fingerprinting but is simple, reliable, and sufficient for most Jekyll sites.

Handling images efficiently in Jekyll

Images are the largest assets in most Jekyll sites and the primary target for performance optimisation. The goal is to serve each image at the smallest file size that maintains acceptable visual quality, in the correct format, at exactly the dimensions needed for the display context.

WebP is the correct format for photographs and complex images in 2025. It is smaller than JPEG at equivalent quality and supported by all modern browsers. PNG remains appropriate for logos, icons, and graphics with transparency or hard edges where lossless compression matters. Convert JPEG originals to WebP using ImageMagick or Squoosh before adding them to the assets folder β€” do not rely on on-the-fly conversion during builds, which is slow and increases build times.

For images that appear in multiple sizes β€” a cover image shown as a thumbnail on the blog index and full-width on the post page β€” provide multiple source files and use the srcset attribute in your HTML to let the browser select the appropriate size. The <picture> element with multiple <source> children gives you full control: WebP for modern browsers, JPEG fallback for older ones, and different crop ratios for different breakpoints if needed.

Store images in a dedicated folder rather than at the root of _posts/. Images co-located with post files work (Jekyll processes files in _posts/ subdirectories) but create clutter and make bulk operations on all images harder. A single assets/images/posts/ directory or an organisation by year (assets/images/2025/) keeps images findable and manageable.

JavaScript in Jekyll: the right amount

Jekyll’s JavaScript story is deliberate minimalism. The framework provides no JavaScript of its own β€” your theme and your customisations provide whatever JavaScript the site needs. This is a feature, not a limitation: it means you only load JavaScript you have consciously chosen to include.

Audit your theme’s JavaScript dependencies before using it. Open the network tab in browser DevTools and load the live demo. Check what scripts are loaded, how large they are, and whether they are render-blocking. A theme that loads jQuery (87KB gzipped) for dropdown menus that could be handled with 15 lines of vanilla JavaScript is carrying avoidable weight. A theme that loads a 50KB animation library for a hero section that animates only on first load is optimisable.

Modularise your JavaScript into page-specific files and load them conditionally. A post page should load post.js (table of contents, back-to-top button, copy-to-clipboard). The homepage loads home.js (carousel, testimonial rotation). The theme listing page loads themes.js (filtering, sorting, search). Loading all JavaScript on every page increases parse and execution time on pages that do not need it. Conditional loading β€” {% if page.layout == 'post' %}<script src="/assets/js/post.js" defer></script>{% endif %} in your layout β€” is a structural performance improvement that compounds across every page visit.

Static files as a deployment advantage

One of Jekyll’s most underappreciated properties is that every output file β€” HTML, CSS, JavaScript, images, fonts β€” is a static file that can be cached aggressively at the CDN edge. There are no API calls, no database queries, no server-side rendering that must complete before a response can be sent. Every resource the browser requests is pre-computed, pre-cached, and pre-positioned at an edge node near the visitor.

This architecture means a Jekyll site’s performance does not degrade under traffic spikes. The thousandth concurrent visitor gets the same response time as the first, because the response is a cached file delivered from a CDN node, not a dynamically generated page competing for server resources. For content that occasionally goes viral β€” a post that gets linked from a popular newsletter, a theme that gets featured on a high-traffic developer site β€” the static file architecture handles the traffic surge without additional cost or configuration.

Treat this architectural advantage as a reason to build your site correctly the first time: choose the right CDN host, configure caching headers properly, and optimise your build pipeline. The investment in static file architecture pays dividends every time a visitor loads a page, every time a search crawler indexes a URL, and every time traffic spikes beyond what a dynamic hosting tier would handle comfortably.

Share LinkedIn