Frontend Assets Since 3.5.0
T3 doesn’t own your CSS or JavaScript build pipeline. You choose the tools, write the source files, and output the compiled assets to a public directory. T3’s asset functions (cms.builder.css(), cms.builder.js(), etc.) handle loading them into your templates with automatic cache busting.
This guide covers the recommended setup using Vite, with notes on alternatives at the end.
Quick Start
Section titled “Quick Start”The fastest path is the bundled scaffold — tcms builder:frontend drops a pre-configured frontend/ directory (with package.json, vite.config.js, and a starter css/ + js/) into your project. From your project root (same level as tcms-data/):
tcms builder:frontendcd frontendnpm installnpm run buildThat installs Vite, builds the starter assets to public/assets/, and writes the manifest T3’s asset helpers consume. From there you edit frontend/css/style.css and frontend/js/app.js, and re-run npm run build (or npm run watch for continuous rebuild).
If you’d rather build the scaffold yourself instead of using the bundled one, the rest of this guide walks through what tcms builder:frontend actually does — vite.config.js, package scripts, and the layout conventions T3’s helpers expect.
Directory Structure
Section titled “Directory Structure”A typical T3 builder project looks like this:
project/├── tcms-data/│ └── builder/│ ├── layouts/│ ├── pages/│ └── partials/├── public/ ← docroot (web server serves this)│ ├── assets/ ← compiled output (Vite writes here)│ │ ├── style.css│ │ ├── app.js│ │ └── manifest.json│ └── index.php ← T3 entry point└── frontend/ ← source files (not public) ├── css/ │ └── style.css ├── js/ │ └── app.js ├── vite.config.js └── package.jsonSource files live outside the docroot in frontend/. Vite compiles them to public/assets/ where the web server serves them directly.
Vite Configuration
Section titled “Vite Configuration”import { defineConfig } from 'vite'import { resolve } from 'path'
export default defineConfig({ build: { // Output to the public assets directory outDir: resolve(__dirname, '../public/assets'), emptyOutDir: true,
// Flatten output: hashed files go directly into outDir, not into a // nested `assets/` subdirectory (Vite's default `assetsDir: 'assets'`). // Otherwise paths would double up as `/assets/assets/style-<hash>.css`. assetsDir: '',
// Vite 5+ writes the manifest to `<outDir>/.vite/manifest.json` by // default. Pin it at `<outDir>/manifest.json` so T3 finds it without // any extra config. (T3 falls back to `.vite/manifest.json` for BYO // projects that don't override this, but it's cleaner to be explicit.) manifest: 'manifest.json',
rollupOptions: { input: { style: resolve(__dirname, 'css/style.css'), app: resolve(__dirname, 'js/app.js'), }, }, },})What This Does
Section titled “What This Does”outDir— writes compiled files topublic/assets/assetsDir: ''— flattens the output (noassets/assets/double-prefix)manifest: 'manifest.json'— places the manifest where T3 looks firstrollupOptions.input— defines your entry points (add as many as needed)
Package Scripts
Section titled “Package Scripts”{ "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }}Development Workflow
Section titled “Development Workflow”Without Vite Dev Server
Section titled “Without Vite Dev Server”The simplest approach — run npm run build after making changes:
cd frontendnpm run buildVite compiles your source files to public/assets/. T3’s asset functions pick them up automatically via mtime cache busting.
For faster iteration, use Vite’s watch mode:
npm run build -- --watchWith Vite Dev Server
Section titled “With Vite Dev Server”For hot module replacement (HMR) during development, run the Vite dev server and reference its URL directly in your layout during development:
{# layouts/default.twig #}{% if cms.env == 'development' %} <script type="module" src="http://localhost:5173/js/app.js"></script>{% else %} {{ cms.builder.css('style.css') }} {{ cms.builder.js('app.js', {module: true}) }}{% endif %}Start the dev server:
cd frontendnpm run devThe Vite dev server handles CSS injection via JavaScript in dev mode, so you only need the JS script tag. In production, cms.builder.css() and cms.builder.js() load the compiled, hashed files from the manifest.
Production Build
Section titled “Production Build”cd frontendnpm run buildThis generates hashed filenames and a manifest.json:
public/assets/├── style.a1b2c3d4.css├── app.e5f6a7b8.js└── manifest.jsonT3’s asset functions read the manifest and output the correct filenames:
{{ cms.builder.css('css/style.css') }}{# Output: <link rel="stylesheet" href="/assets/style.a1b2c3d4.css"> #}
{{ cms.builder.js('js/app.js', {module: true}) }}{# Output: <script type="module" src="/assets/app.e5f6a7b8.js"></script> #}The path you pass to css() and js() is the manifest key (the input path from your Vite config), not the output filename.
Using Tailwind CSS
Section titled “Using Tailwind CSS”Tailwind works naturally with this setup:
cd frontendnpm install -D tailwindcss @tailwindcss/viteimport { defineConfig } from 'vite'import { resolve } from 'path'import tailwindcss from '@tailwindcss/vite'
export default defineConfig({ plugins: [tailwindcss()], build: { outDir: resolve(__dirname, '../public/assets'), emptyOutDir: true, assetsDir: '', manifest: 'manifest.json', rollupOptions: { input: { style: resolve(__dirname, 'css/style.css'), app: resolve(__dirname, 'js/app.js'), }, }, },})@import "tailwindcss";Point Tailwind at your templates so it can scan for classes:
@import "tailwindcss";@source "../../tcms-data/builder/**/*.twig";Using Sass/SCSS
Section titled “Using Sass/SCSS”cd frontendnpm install -D sassVite handles .scss files automatically — just change your entry point:
rollupOptions: { input: { style: resolve(__dirname, 'css/style.scss'), app: resolve(__dirname, 'js/app.js'), },},Core Total CMS Assets
Section titled “Core Total CMS Assets”Before your own assets, your layout needs the ones Total CMS ships. cms.assetsHead() and cms.assetsBody() emit the core stylesheets and scripts that T3’s own Twig output depends on — grid layout, galleries, pagination, icons, htmx, and the decoder behind the mailto filter — plus anything registered by extensions.
{{ cms.assetsHead() }} {# in <head>, before your own CSS #}{{ cms.assetsBody() }} {# just before </body> #}These are separate from the cms.builder.* functions below. cms.builder.css() and cms.builder.js() load your files out of the builder assets directory; cms.assetsHead() / cms.assetsBody() load Total CMS’s. A layout wants both.
Leave them out and nothing errors — {% cmsgrid %} renders unstyled, galleries don’t lay out, Load More doesn’t fetch, and obfuscated email addresses never become links. See Frontend Assets in the Twig overview for the full symptom table.
Put cms.assetsHead() before your own stylesheet so your rules override the defaults.
Layout Template Example
Section titled “Layout Template Example”A complete layout using all asset functions:
<!DOCTYPE html><html lang="en"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{% block title %}{{ page.title }}{% endblock %}</title> <meta name="description" content="{{ page.description }}">
{# Total CMS core assets first, so your own CSS can override them #} {{ cms.assetsHead() }}
{{ cms.builder.preload('fonts/inter.woff2', 'font') }} {{ cms.builder.css('css/style.css') }}</head><body> {% include 'partials/nav.twig' %}
<main>{% block content %}{% endblock %}</main>
{% include 'partials/footer.twig' %}
{{ cms.assetsBody() }} {{ cms.builder.js('js/app.js', {module: true}) }}</body></html>Without a Build Tool
Section titled “Without a Build Tool”If you don’t need a build pipeline, just place plain CSS and JS files directly in public/assets/:
public/assets/├── style.css└── app.js{{ cms.assetsHead() }}{{ cms.builder.css('style.css') }}
{{ cms.assetsBody() }}{{ cms.builder.js('app.js') }}T3 adds ?v={mtime} query strings for cache busting. No manifest, no build step — just files. The core asset helpers are still required with or without a build tool.
Alternatives to Vite
Section titled “Alternatives to Vite”esbuild
Section titled “esbuild”const esbuild = require('esbuild')
esbuild.build({ entryPoints: ['css/style.css', 'js/app.js'], outdir: '../public/assets', bundle: true, minify: true,})esbuild doesn’t generate a Vite-compatible manifest by default. You can use the esbuild-plugin-manifest package or rely on mtime cache busting.
Webpack
Section titled “Webpack”Webpack works but is heavier than Vite for this use case. Use webpack-manifest-plugin to generate a compatible manifest.json.
See Also
Section titled “See Also”- Builder Twig Reference —
css(),js(),asset(),preload()documentation - Site Builder Overview