Frontend Assets

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

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

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

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

Vite Configuration

// 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'),
            },
        },
    },
})

What This Does

  • 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)

Package Scripts

{
    "scripts": {
        "dev": "vite",
        "build": "vite build",
        "preview": "vite preview"
    }
}

Development Workflow

Without Vite Dev Server

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

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:

npm run build -- --watch

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

Production Build

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.

Using Tailwind CSS

Tailwind works naturally with this setup:

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";

Using Sass/SCSS

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'),
    },
},

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 }}">

    {{ 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>

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

Alternatives to Vite

esbuild

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

Webpack works but is heavier than Vite for this use case. Use webpack-manifest-plugin to generate a compatible manifest.json.

See Also