Skip to main content

Asset Directory Structure

All static assets must be placed in the templates/assets/ directory within your theme:
my-theme/
└── templates/
    └── assets/
        ├── css/
        │   ├── style.css
        │   ├── dark-mode.css
        │   └── vendor/
        │       └── normalize.css
        ├── js/
        │   ├── main.js
        │   ├── search.js
        │   └── vendor/
        │       └── jquery.min.js
        ├── images/
        │   ├── logo.svg
        │   ├── default-avatar.png
        │   └── icons/
        └── fonts/
            └── custom-font.woff2
Assets must be in templates/assets/ - this is enforced by Halo’s asset resolution system. Assets placed elsewhere will not be accessible.

How Asset Resolution Works

Halo uses a custom resource resolver to serve theme assets:
application/src/main/java/run/halo/app/theme/config/ThemeWebFluxConfigurer.java
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/themes/{themeName}/assets/{*resourcePaths}")
        .setCacheControl(cacheControl)
        .setUseLastModified(useLastModified)
        .resourceChain(true)
        .addResolver(new EncodedResourceResolver())
        .addResolver(new ThemePathResourceResolver(themeRootGetter.get()));
}
Assets are resolved to the actual file path:
var assetsPath = themeRoot.resolve(themeName + "/templates/assets/" + resourcePaths);

Asset URL Pattern

Assets are served at:
/themes/{theme-name}/assets/{path-to-asset}
For example:
  • /themes/my-theme/assets/css/style.css
  • /themes/my-theme/assets/js/main.js
  • /themes/my-theme/assets/images/logo.png

Referencing Assets in Templates

Using the #theme.assets() Function

The recommended way to reference assets is with the #theme.assets() function:
<!-- CSS -->
<link rel="stylesheet" th:href="@{${#theme.assets('/css/style.css')}}" />

<!-- JavaScript -->
<script th:src="@{${#theme.assets('/js/main.js')}}"></script>

<!-- Images -->
<img th:src="@{${#theme.assets('/images/logo.png')}}" alt="Logo" />

<!-- Fonts in CSS -->
@font-face {
    font-family: 'CustomFont';
    src: url('/assets/fonts/custom-font.woff2') format('woff2');
}

How #theme.assets() Works

The function is implemented in DefaultLinkExpressionFactory:
application/src/main/java/run/halo/app/theme/dialect/DefaultLinkExpressionFactory.java
public class ThemeLinkExpressObject {
    public String assets(String path) {
        String assetsPath = ThemeLinkBuilder.THEME_ASSETS_PREFIX + path;
        return linkBuilder.buildLink(context, assetsPath, null);
    }
}
The link builder handles:
  • Active themes: Returns /assets{path} directly
  • Preview mode: Returns /themes/{theme-name}/assets{path}
application/src/main/java/run/halo/app/theme/ThemeLinkBuilder.java
private boolean isAssetsRequest(String link) {
    String assetsPrefix = externalUrlSupplier.get().resolve(THEME_ASSETS_PREFIX).toString();
    return link.startsWith(assetsPrefix) || link.startsWith(THEME_ASSETS_PREFIX);
}

if (isAssetsRequest(link)) {
    return PathUtils.combinePath(THEME_PREVIEW_PREFIX, theme.getName(), link);
}
Using #theme.assets() ensures your assets work correctly in both active and preview modes.

CSS Stylesheets

Linking Stylesheets

<head>
    <!-- Main stylesheet -->
    <link rel="stylesheet" th:href="@{${#theme.assets('/css/style.css')}}" />
    
    <!-- Dark mode (conditional) -->
    <link rel="stylesheet" 
          th:href="@{${#theme.assets('/css/dark-mode.css')}}" 
          th:if="${theme.config.darkMode}" />
    
    <!-- Print styles -->
    <link rel="stylesheet" 
          th:href="@{${#theme.assets('/css/print.css')}}" 
          media="print" />
</head>

Inline Styles

For small, dynamic styles:
<style th:inline="text">
    :root {
        --primary-color: [[${theme.config.primaryColor}]];
        --font-family: [[${theme.config.fontFamily}]];
    }
</style>

CSS Best Practices

1

Use CSS Variables

Define customizable values as CSS variables:
:root {
    --primary-color: #007bff;
    --secondary-color: #6c757d;
    --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto;
    --spacing-unit: 1rem;
}
2

Organize with Comments

Structure your CSS with clear sections:
/* ==========================================================================
   Typography
   ========================================================================== */

/* ==========================================================================
   Layout
   ========================================================================== */
3

Use Responsive Design

Implement mobile-first responsive layouts:
.container {
    padding: 1rem;
}

@media (min-width: 768px) {
    .container {
        padding: 2rem;
    }
}

JavaScript Files

Loading Scripts

<body>
    <!-- Content -->
    
    <!-- Scripts at end of body for better performance -->
    <script th:src="@{${#theme.assets('/js/vendor/jquery.min.js')}}"></script>
    <script th:src="@{${#theme.assets('/js/main.js')}}"></script>
</body>

Async and Defer

<!-- Non-critical scripts - load asynchronously -->
<script th:src="@{${#theme.assets('/js/analytics.js')}}" async></script>

<!-- Scripts that depend on DOM - defer until parsing complete -->
<script th:src="@{${#theme.assets('/js/ui-enhancements.js')}}" defer></script>

Inline Scripts with Template Data

<script th:inline="javascript">
    const siteConfig = {
        title: /*[[${site.title}]]*/ 'Default Title',
        baseUrl: /*[[${site.url}]]*/ 'https://example.com',
        theme: {
            primaryColor: /*[[${theme.config.primaryColor}]]*/ '#007bff'
        }
    };
    
    console.log('Site:', siteConfig.title);
</script>

Module Scripts

<script type="module" th:src="@{${#theme.assets('/js/app.js')}}"></script>

Images

Image References

<!-- Logo -->
<img th:src="@{${#theme.assets('/images/logo.png')}}" 
     alt="Site Logo" 
     width="200" 
     height="60" />

<!-- Default avatar fallback -->
<img th:src="${user.avatar} ?: ${#theme.assets('/images/default-avatar.png')}" 
     th:alt="${user.name}" />

<!-- Background image in CSS -->
<div class="hero" 
     th:style="'background-image: url(' + ${#theme.assets('/images/hero-bg.jpg')} + ')'"></div>

Responsive Images

<picture>
    <source th:srcset="@{${#theme.assets('/images/hero-large.jpg')}}" 
            media="(min-width: 1200px)" />
    <source th:srcset="@{${#theme.assets('/images/hero-medium.jpg')}}" 
            media="(min-width: 768px)" />
    <img th:src="@{${#theme.assets('/images/hero-small.jpg')}}" 
         alt="Hero Image" />
</picture>

Image Optimization Tips

Use Modern Formats

Provide WebP or AVIF formats with fallbacks:
<picture>
    <source type="image/webp" srcset="image.webp" />
    <img src="image.jpg" alt="..." />
</picture>

Lazy Loading

Use native lazy loading for images:
<img src="..." loading="lazy" alt="..." />

Proper Sizing

Always specify width and height to prevent layout shift:
<img src="..." width="800" height="600" alt="..." />

Compression

Compress images before including them in your theme using tools like ImageOptim or Squoosh.

Fonts

Custom Web Fonts

templates/assets/css/fonts.css
@font-face {
    font-family: 'CustomFont';
    src: url('/assets/fonts/custom-font.woff2') format('woff2'),
         url('/assets/fonts/custom-font.woff') format('woff');
    font-weight: normal;
    font-style: normal;
    font-display: swap;
}

body {
    font-family: 'CustomFont', -apple-system, BlinkMacSystemFont, sans-serif;
}

Google Fonts

<head>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" 
          rel="stylesheet" />
</head>

Vendor Libraries

Organizing Third-Party Assets

Keep vendor files separate from your custom code:
templates/assets/
├── css/
│   ├── style.css          # Your custom styles
│   └── vendor/
│       ├── normalize.css
│       └── highlight.css
└── js/
    ├── main.js            # Your custom scripts
    └── vendor/
        ├── jquery.min.js
        └── highlight.min.js

Loading Order

<!-- Load vendor libraries first -->
<script th:src="@{${#theme.assets('/js/vendor/jquery.min.js')}}"></script>
<script th:src="@{${#theme.assets('/js/vendor/bootstrap.bundle.min.js')}}"></script>

<!-- Then load your custom scripts -->
<script th:src="@{${#theme.assets('/js/main.js')}}"></script>

Asset Caching

Halo configures caching for theme assets based on Spring Boot’s resource properties:
application/src/main/java/run/halo/app/theme/config/ThemeWebFluxConfigurer.java
var cacheControl = resourcesProperties.getCache().getCachecontrol().toHttpCacheControl();
var useLastModified = resourcesProperties.getCache().isUseLastModified();

registry.addResourceHandler("/themes/{themeName}/assets/{*resourcePaths}")
    .setCacheControl(cacheControl)
    .setUseLastModified(useLastModified)
    .resourceChain(true)
    .addResolver(new EncodedResourceResolver())
    .addResolver(new ThemePathResourceResolver(themeRootGetter.get()));

Cache Busting

For development or when assets change frequently, append version query parameters:
<link rel="stylesheet" 
      th:href="@{${#theme.assets('/css/style.css')} + '?v=' + ${theme.spec.version}}" />

Performance Optimization

1

Minimize HTTP Requests

Combine CSS and JavaScript files where possible:
<!-- Instead of multiple CSS files -->
<link rel="stylesheet" th:href="@{${#theme.assets('/css/bundle.css')}}" />
2

Minify Assets

Use minified versions of CSS and JavaScript in production:
assets/css/
├── style.css        # Development
└── style.min.css    # Production
3

Use CDNs for Common Libraries

Load popular libraries from CDNs:
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js"
        integrity="sha256-..."
        crossorigin="anonymous"></script>
4

Lazy Load Non-Critical Assets

Load images and scripts only when needed:
<img src="..." loading="lazy" />
<script src="..." defer></script>
5

Enable Compression

The EncodedResourceResolver automatically serves pre-compressed assets (.gz, .br) if available.

Example: Complete Asset Setup

templates/partials/head.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="head(title)">
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <meta name="description" th:content="${site.description}" />
    
    <!-- Title -->
    <title th:text="${title} + ' - ' + ${site.title}">Page Title - Site</title>
    
    <!-- Favicon -->
    <link rel="icon" type="image/png" 
          th:href="@{${site.favicon} ?: ${#theme.assets('/images/favicon.png')}}" />
    
    <!-- Fonts -->
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" 
          rel="stylesheet" />
    
    <!-- Stylesheets -->
    <link rel="stylesheet" th:href="@{${#theme.assets('/css/normalize.css')}}" />
    <link rel="stylesheet" th:href="@{${#theme.assets('/css/style.css')}}" />
    <link rel="stylesheet" 
          th:href="@{${#theme.assets('/css/dark-mode.css')}}" 
          th:if="${theme.config.darkMode}" />
    
    <!-- Theme configuration -->
    <style th:inline="text">
        :root {
            --primary-color: [[${theme.config.primaryColor ?: '#007bff'}]];
            --font-family: [[${theme.config.fontFamily ?: 'Inter, sans-serif'}]];
        }
    </style>
    
    <!-- Analytics (async) -->
    <script th:if="${theme.config.analytics}" 
            th:src="@{${#theme.assets('/js/analytics.js')}}" 
            async></script>
</head>
</html>
templates/partials/footer.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
    <div th:fragment="scripts">
        <!-- Vendor libraries -->
        <script th:src="@{${#theme.assets('/js/vendor/jquery.min.js')}}"></script>
        
        <!-- Main application script -->
        <script th:src="@{${#theme.assets('/js/main.js')}}"></script>
        
        <!-- Optional: Search functionality -->
        <script th:if="${theme.config.enableSearch}" 
                th:src="@{${#theme.assets('/js/search.js')}}" 
                defer></script>
    </div>
</body>
</html>

Security Considerations

Always validate and sanitize any user input before using it in asset URLs or inline scripts to prevent XSS attacks.

Subresource Integrity (SRI)

When loading assets from CDNs, use SRI hashes:
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js"
        integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
        crossorigin="anonymous"></script>

Content Security Policy

Consider implementing CSP headers to restrict asset sources:
<meta http-equiv="Content-Security-Policy" 
      content="default-src 'self'; style-src 'self' 'unsafe-inline' fonts.googleapis.com; font-src fonts.gstatic.com;" />

Next Steps

Configuration

Learn how to configure theme settings and options

Templates

Master Thymeleaf template development

Build docs developers (and LLMs) love