Symfony SaaS without Node: AssetMapper + Tailwind CSS 4
A full production SaaS — Tailwind 4, Stimulus, Turbo, a streaming AI chat — with no package.json, no node_modules and no bundler. The exact AssetMapper setup, dev loop and deploy pipeline, from a shipping codebase.
There is no package.json in ShipAnvil. No node_modules, no
Webpack, no Vite, no npm install in CI, no Node.js on the production
server. And yet it's a complete SaaS frontend: Tailwind-styled pages, Turbo
navigation, Stimulus controllers, a checkout, an AI chat that streams
tokens over SSE.
This isn't an ascetic stunt. Since Symfony moved to AssetMapper, the entire Node toolchain is optional for the kind of frontend most SaaS products actually have — server-rendered pages with islands of behavior. Here's the exact setup, from a codebase that ships, with the sharp edges we hit.
What replaces the bundler
Two mechanisms, both boring in the best way:
- AssetMapper maps your
assets/directory to public, versioned URLs and writes an import map into the page — the browser resolvesimportstatements natively. No compilation step for JavaScript at all. - The Tailwind standalone binary compiles your CSS. It's a single self-contained executable that Symfony downloads for you once — Tailwind without the npm ecosystem attached.
Modern browsers have supported import maps since early 2023. What bundlers still buy you — tree-shaking, JSX, TypeScript transpilation — is exactly what a Twig-first app doesn't use.
JavaScript: an importmap and nothing else
The whole JavaScript dependency manifest of the kit is importmap.php:
return [
'app' => [
'path' => './assets/app.js',
'entrypoint' => true,
],
'@hotwired/stimulus' => [
'version' => '3.2.2',
],
'@symfony/stimulus-bundle' => [
'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js',
],
'@hotwired/turbo' => [
'version' => '8.0.23',
],
];
Adding a library is one command, no lockfile ceremony:
php bin/console importmap:require @hotwired/turbo
It downloads the package into assets/vendor/ and pins the version in
importmap.php. We gitignore assets/vendor/ and let
importmap:install re-download pinned versions on fresh clones — same
model as Composer, and composer.lock-style reproducibility comes from the
pinned versions in the map.
Your base.html.twig renders the map (note the CSP nonce — more on that
below):
{{ importmap('app', {nonce: csp_nonce()}) }}
Tailwind CSS 4: one binary, one line of CSS
Tailwind is the reason most Symfony projects kept Node around. The symfonycasts/tailwind-bundle removes that reason: it downloads the official standalone binary and wraps it in console commands.
# config/packages/symfonycasts_tailwind.yaml
symfonycasts_tailwind:
binary_version: 'v4.1.11'
Pin the version — "whatever was latest at deploy time" is not a build
input. And because Tailwind 4 went CSS-first, there is no
tailwind.config.js either. The kit's entire Tailwind configuration is
the first line of assets/styles/app.css:
@import "tailwindcss";
That's the whole file. Tailwind 4 scans your templates for class names
automatically; design tokens, when you want them, are declared in CSS with
@theme rather than in a JS config. Our landing, admin, emails and blog
are styled from this one line plus utilities in Twig.
The stylesheet is linked normally — the bundle transparently serves the compiled output for that path:
<link rel="stylesheet" href="{{ asset('styles/app.css') }}">
The development loop
Two terminals:
make serve # the Symfony local server
make watch # php bin/console tailwind:build --watch
The watcher recompiles the CSS on every template save, faster than any
Webpack dev-server we've retired. JavaScript needs no watcher at all —
edit a Stimulus controller, reload, done. A new machine bootstraps with
make init: Composer, database, assets, tests — Node never enters.
Production: compile to static files
Deployment is four commands, none of which is npm:
php bin/console assets:install public # bundle assets (EasyAdmin…)
php bin/console importmap:install # JS vendors (pinned versions)
php bin/console tailwind:build --minify # CSS, via the same binary
php bin/console asset-map:compile # fingerprinted files in public/
asset-map:compile writes every asset to public/assets/ with a content
hash in the filename, so your web server serves plain static files with
far-future cache headers and PHP is never in the asset path. Total
"frontend build infrastructure" on the server: one downloaded binary,
cached between deploys.
Interactivity without a SPA
"No bundler" does not mean "no JavaScript". The kit's most dynamic feature — the Claude chat that streams responses token by token — is a Stimulus controller consuming Server-Sent Events, delivered through the importmap like everything else. Turbo makes navigation feel app-like without a router rewrite; Stimulus handles the checkout flow, the clipboard buttons, the CSRF cookie dance.
The honest framing: this stack covers the frontend of a SaaS — forms, dashboards, modals, streams. It is not trying to cover Figma.
Sharp edges we actually hit
- Content Security Policy. An importmap is an inline
<script>. If you send a CSP (you should), pass the nonce:importmap('app', {nonce: csp_nonce()}). Forgetting this breaks every page's JavaScript in the exact environment — production — where you finally enabled the strict policy. - Set
missing_import_mode: strictin dev. A typo'd import path then fails loudly at render time instead of 404ing quietly in the browser console. We relax it towarnin production. - Icons: use SVG, not icon fonts.
symfony/ux-iconsinlines SVGs server-side fromassets/icons/(we vendor the Lucide set) — zero network requests, styleable withcurrentColor, and one less font origin in the CSP. - Third-party admin styles coexist fine. EasyAdmin ships its own CSS;
our
styles/admin.cssonly overrides its CSS variables and is attached in the dashboard controller — no bundler gymnastics required to theme a bundle.
When you still need Node
Keeping the boundary honest: reach for a bundler when you have a genuine SPA (React/Vue islands with JSX), TypeScript you must transpile, or a design system built on PostCSS plugins beyond Tailwind. AssetMapper even lets you mix — some teams compile one React island with Vite and serve the rest through the importmap. If your product is Twig pages with behavior, though, you'll likely never open that door.
Steal it or ship it
Everything above is standard Symfony — the docs linked in each section get you there in an afternoon, and I'd recommend that afternoon to any team still paying the Webpack tax on a Twig app.
If you'd rather start from a codebase where it's already wired — together with auth, 2FA, teams, billing on two providers, an admin, and 327 tests holding it all down — that's exactly what ShipAnvil ships. The live demo runs this exact stack; view-source and you'll find the importmap.