Skip to content

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.

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/):

Terminal window
tcms builder:frontend
cd frontend
npm install
npm run build

That 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.

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.json

Source files live outside the docroot in frontend/. Vite compiles them to public/assets/ where the web server serves them directly.

frontend/vite.config.js
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'),
},
},
},
})
  • outDir — writes compiled files to public/assets/
  • assetsDir: '' — flattens the output (no assets/assets/ double-prefix)
  • manifest: 'manifest.json' — places the manifest where T3 looks first
  • rollupOptions.input — defines your entry points (add as many as needed)
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}

The simplest approach — run npm run build after making changes:

Terminal window
cd frontend
npm run build

Vite 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:

Terminal window
npm run build -- --watch

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:

Terminal window
cd frontend
npm run dev

The 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.

Terminal window
cd frontend
npm run build

This generates hashed filenames and a manifest.json:

public/assets/
├── style.a1b2c3d4.css
├── app.e5f6a7b8.js
└── manifest.json

T3’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.

Tailwind works naturally with this setup:

Terminal window
cd frontend
npm install -D tailwindcss @tailwindcss/vite
frontend/vite.config.js
import { 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'),
},
},
},
})
frontend/css/style.css
@import "tailwindcss";

Point Tailwind at your templates so it can scan for classes:

frontend/css/style.css
@import "tailwindcss";
@source "../../tcms-data/builder/**/*.twig";
Terminal window
cd frontend
npm install -D sass

Vite handles .scss files automatically — just change your entry point:

vite.config.js
rollupOptions: {
input: {
style: resolve(__dirname, 'css/style.scss'),
app: resolve(__dirname, 'js/app.js'),
},
},

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 }}">
{{ 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.builder.js('js/app.js', {module: true}) }}
</body>
</html>

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.builder.css('style.css') }}
{{ cms.builder.js('app.js') }}

T3 adds ?v={mtime} query strings for cache busting. No manifest, no build step — just files.

build.js
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 works but is heavier than Vite for this use case. Use webpack-manifest-plugin to generate a compatible manifest.json.