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 resolves import statements 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: strict in dev. A typo'd import path then fails loudly at render time instead of 404ing quietly in the browser console. We relax it to warn in production.
  • Icons: use SVG, not icon fonts. symfony/ux-icons inlines SVGs server-side from assets/icons/ (we vendor the Lucide set) — zero network requests, styleable with currentColor, and one less font origin in the CSP.
  • Third-party admin styles coexist fine. EasyAdmin ships its own CSS; our styles/admin.css only 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.