How to Prevent Duplicate CSS When Merging Tailwind 4 Output at Build and Runtime?

Hi. Recently, I’ve been building a page builder in Phoenix that runs in the runtime environment and actively accepts Tailwind code (Runtime compiling module and assets).
I’ve recently been working on implementing a system that allows me to merge the Tailwind CSS classes generated at build time with additional CSS classes generated at runtime using the Tailwind binary. (I am using Tailwind 4)

Unfortunately, the issue I’m facing is CSS duplication in my source code, which increases the overall size of the output.


In the development environment, the source code is fully accessible, and Tailwind can easily rescan the modules and transfer all the classes into the CSS file. However, after building the Phoenix project’s release, this capability is no longer available (or maybe i do not know how can scan the html part in production mode).


Now, based on the approach used by Beacon, I store the modules that I dynamically create at runtime in a .template text file and pass them to the Tailwind CLI. Tailwind then processes and compiles the CSS accordingly.

Everything works, but the only issue is that we also need the CSS that was originally compiled into Phoenix’s app.css during compile time and placed in the priv directory. Since both include Tailwind imports, we end up with a lot of duplicated styles.

What do you suggest?

This is simple Tailwind module that I use with Phoenix 1.8 and Tailwind 4

Gist: tailwind_compiler.ex · GitHub

defmodule MishkaCms.Runtime.Compilers.TailwindCompiler do @moduledoc """ Production-ready Tailwind 4 compiler for runtime CSS generation. Creates sample templates and compiles them to CSS using Tailwind CLI. """ require Logger @doc """ Compiles Tailwind CSS from hardcoded templates. Returns {:ok, css} or {:error, reason} """ def compile do # Use :code.priv_dir for production compatibility tmp_dir = get_temp_dir() # Ensure directory exists (important for releases) _ = File.mkdir_p(tmp_dir) try do # Generate sample template files _ = generate_sample_templates!(tmp_dir) # Run Tailwind compilation output_css = execute_tailwind() {:ok, output_css} rescue e -> {:error, Exception.message(e)} after # Cleanup templates but keep the directory cleanup_templates(tmp_dir) end end defp get_temp_dir do # In production/release: uses the app's priv directory # In development: falls back to relative path case :code.priv_dir(:mishka_cms) do {:error, _} -> Path.join(["priv", "static", "temp"]) priv_dir -> Path.join([priv_dir, "static", "temp"]) end end defp generate_sample_templates!(tmp_dir) do templates = [ {"sample_1.template", """ <div> <h1 id="header1" class="text-center w-full bg-gray-200 p-4 border border-gray-300 rounded-md shadow-md"> <%= @page_data.page.title %> </h1> <p class="mishka-test">Count: <%= @page_data.extra.count || 0 %></p> <br /> <button phx-click={JS.toggle_class("text-red-500", to: "#header1")} class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" > Change Color </button> </div> """} # {"sample_2.template", # """ # <nav class="bg-gray-800 p-4"> # <div class="flex items-center justify-between"> # <span class="text-white text-xl font-semibold">My App</span> # <div class="flex space-x-4"> # <a href="#" class="text-gray-300 hover:text-white transition-colors">Home</a> # <a href="#" class="text-gray-300 hover:text-white transition-colors">About</a> # <a href="#" class="text-gray-300 hover:text-white transition-colors">Contact</a> # </div> # </div> # </nav> # """}, # {"sample_3.template", # """ # <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-6"> # <div class="bg-white rounded-lg shadow-md p-6"> # <h2 class="text-xl font-semibold mb-2">Card Title</h2> # <p class="text-gray-600">Card content goes here.</p> # </div> # <div class="bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-lg p-6"> # <h2 class="text-xl font-semibold mb-2">Gradient Card</h2> # <p>Another card with gradient background.</p> # </div> # </div> # """}, # {"sample_4.template", # """ # <form class="max-w-md mx-auto mt-8 space-y-4"> # <input type="text" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Enter your name"> # <textarea class="w-full px-4 py-2 border border-gray-300 rounded-md resize-none focus:ring-2 focus:ring-blue-500" rows="4" placeholder="Your message"></textarea> # <button type="submit" class="w-full bg-green-600 hover:bg-green-700 text-white font-medium py-2 px-4 rounded-md transition-colors"> # Submit # </button> # </form> # """} ] Enum.map(templates, fn {filename, content} -> filepath = Path.join(tmp_dir, filename) File.write!(filepath, content) filepath end) end defp execute_tailwind do # Create temporary input CSS file with dynamic content timestamp = :os.system_time(:millisecond) input_css_path = Path.join(System.tmp_dir!(), "tailwind_input_#{timestamp}.css") output_css_path = Path.join(System.tmp_dir!(), "tailwind_output_#{timestamp}.css") # Write CSS to temporary file _ = File.write!(input_css_path, build_css_content()) try do # Check if Tailwind binary exists, install if not if !File.exists?(Tailwind.bin_path()) do Logger.info("Tailwind binary not found at #{Tailwind.bin_path()}. Installing...") Tailwind.install() end # Build arguments for Tailwind CLI # Always minify in production-ready code # args = ["--input=#{input_css_path}", "--output=#{output_css_path}", "--minify"] args = ["--input=#{input_css_path}", "--output=#{output_css_path}"] # Run Tailwind CLI case System.cmd(Tailwind.bin_path(), args, cd: File.cwd!(), stderr_to_stdout: true) do {_output, 0} -> File.read!(output_css_path) {error_output, exit_code} -> raise """ Tailwind compilation failed with exit code #{exit_code} Output: #{error_output} Tailwind path: #{Tailwind.bin_path()} Args: #{inspect(args)} """ end after # Cleanup temporary files File.rm(input_css_path) File.rm(output_css_path) end end defp cleanup_templates(tmp_dir) do case File.ls(tmp_dir) do {:ok, files} -> files |> Enum.filter(&String.ends_with?(&1, ".template")) |> Enum.each(&File.rm!(Path.join(tmp_dir, &1))) {:error, _} -> # Directory doesn't exist, nothing to clean :ok end end @doc """ Compiles and stores the CSS in ETS. Creates an ETS table if it doesn't exist. """ def compile_and_store(ets_table_name \\ :tailwind_css_cache) do # Ensure ETS table exists if :ets.info(ets_table_name) == :undefined do :ets.new(ets_table_name, [:set, :public, :named_table]) end case compile() do {:ok, css} -> :ets.insert(ets_table_name, {:compiled_css, css}) Logger.info("Tailwind CSS compiled and stored in ETS") {:ok, css} {:error, reason} = error -> Logger.error("Failed to compile Tailwind CSS: #{reason}") error end end @doc """ Retrieves compiled CSS from ETS. """ def get_compiled_css(ets_table_name \\ :tailwind_css_cache) do case :ets.lookup(ets_table_name, :compiled_css) do [{:compiled_css, css}] -> css [] -> "/* CSS not found for this site */" end end defp build_css_content do temp_dir = get_temp_dir() app_css_path = Path.join([:code.priv_dir(:mishka_cms), "static", "assets", "css", "app.css"]) _existing_css = if File.exists?(app_css_path), do: File.read!(app_css_path), else: "" """ /* Import Tailwind layers once */ @import "tailwindcss" source(none); /* Actually import your custom base app styles */ @import "#{app_css_path}"; /* Also scan app.css and templates for utility classes */ @source "#{app_css_path}"; @source "#{temp_dir}/*.template"; """ end end 

As you can see in the code below, if I don’t call @import "tailwindcss" source(none); again here, the styles—such as colors—won’t compile correctly at runtime. But if I do include it, I end up with duplicate CSS rules.

I place the runtime CSS in ETS and include it in root.html.

 defp build_css_content do temp_dir = get_temp_dir() app_css_path = Path.join([:code.priv_dir(:mishka_cms), "static", "assets", "css", "app.css"]) _existing_css = if File.exists?(app_css_path), do: File.read!(app_css_path), else: "" """ /* Import Tailwind layers once */ @import "tailwindcss" source(none); /* Actually import your custom base app styles */ @import "#{app_css_path}"; /* Also scan app.css and templates for utility classes */ @source "#{app_css_path}"; @source "#{temp_dir}/*.template"; """ end 

Note: if i do not import @import "#{app_css_path}"; again here the custom css which is written by developer is not brought! and does not work

Thank you in advance

Another discussions

1 Like

I’m thinking about copying the contents of some directories at compile time and converting them into a text file so that I can access them at runtime. But this approach takes up a lot of space in the codebase and increases compile time. Or get all class with a regex and write in a file and put in priv dir

Do you think there’s another possible way to access content that contains Tailwind classes at runtime?

Something like this (generated by AI)

defmodule Mix.Tasks.ExtractTailwindClasses do use Mix.Task @shortdoc "Extract Tailwind classes from Elixir modules and write to file" def run(_args) do classes = extract_classes() write_classes_to_file(classes) IO.puts("✅ Extracted #{length(classes)} classes to priv/static/temp/tailwind_classes.txt") end defp extract_classes do # Scan all Elixir files Path.wildcard("lib/**/*.{ex,exs,heex}") |> Enum.flat_map(&extract_from_file/1) |> Enum.uniq() |> Enum.sort() end defp extract_from_file(file_path) do case File.read(file_path) do {:ok, content} -> extract_from_content(content) {:error, _} -> [] end end defp extract_from_content(content) do patterns = [ # class="..." ~r/class\s*=\s*"([^"]+)"/, # class='...' ~r/class\s*=\s*'([^']+)'/, # class: "..." ~r/class:\s*"([^"]+)"/, # class: '...' ~r/class:\s*'([^']+)'/, # class=~s[...] ~r/class\s*=\s*~s\[([^\]]+)\]/, # class="""...""" ~r/class\s*=\s*"""([^"]+)"""/ ] patterns |> Enum.flat_map(fn pattern -> Regex.scan(pattern, content, capture: :all_but_first) |> List.flatten() end) |> Enum.map(&normalize_class_string/1) |> Enum.reject(&(&1 == "" or is_nil(&1))) end defp normalize_class_string(class_string) do class_string |> String.trim() |> String.replace(~r/\s+/, " ") end defp write_classes_to_file(classes) do File.mkdir_p!("priv/static/temp") # Create pseudo-HTML with classes html_content = classes |> Enum.map(&"<div class=\"#{&1}\"></div>") |> Enum.join("\n") File.write!("priv/static/temp/tailwind_classes.html", html_content) end end