shahryarjb
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
First Post!
shahryarjb
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
Popular in Questions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance









