diff --git a/modules/cms/tests/fixtures/npm/package-mixtheme.json b/modules/cms/tests/fixtures/npm/package-mixtheme.json
new file mode 100644
index 0000000000..0a1353fc94
--- /dev/null
+++ b/modules/cms/tests/fixtures/npm/package-mixtheme.json
@@ -0,0 +1,11 @@
+{
+    "type": "module",
+    "workspaces": {
+        "packages": [
+            "modules/cms/tests/fixtures/themes/mixtest"
+        ]
+    },
+    "devDependencies": {
+        "laravel-mix": "^6.0.41"
+    }
+}
diff --git a/modules/cms/tests/fixtures/themes/mixtest/assets/src/css/theme.css b/modules/cms/tests/fixtures/themes/mixtest/assets/src/css/theme.css
new file mode 100644
index 0000000000..d224431f16
--- /dev/null
+++ b/modules/cms/tests/fixtures/themes/mixtest/assets/src/css/theme.css
@@ -0,0 +1,3 @@
+h1 {
+    color: red;
+}
diff --git a/modules/cms/tests/fixtures/themes/mixtest/winter.mix.js b/modules/cms/tests/fixtures/themes/mixtest/winter.mix.js
new file mode 100644
index 0000000000..280c474e4b
--- /dev/null
+++ b/modules/cms/tests/fixtures/themes/mixtest/winter.mix.js
@@ -0,0 +1,10 @@
+const mix = require('laravel-mix');
+
+mix.setPublicPath(__dirname)
+    .options({
+        manifest: true,
+    })
+    .version()
+
+    // Render Tailwind style
+    .postCss('assets/src/css/theme.css', 'assets/dist/css/theme.css')
diff --git a/modules/cms/tests/twig/MixFunctionTest.php b/modules/cms/tests/twig/MixFunctionTest.php
new file mode 100644
index 0000000000..9108b8d89c
--- /dev/null
+++ b/modules/cms/tests/twig/MixFunctionTest.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Cms\Tests\Twig;
+
+use Cms\Classes\Controller;
+use Cms\Classes\Theme;
+use Cms\Twig\Extension;
+use System\Tests\Bootstrap\TestCase;
+use Winter\Storm\Support\Facades\Config;
+use Winter\Storm\Support\Facades\Event;
+use Winter\Storm\Support\Facades\File;
+use Winter\Storm\Support\Facades\Twig;
+
+class MixFunctionTest extends TestCase
+{
+    protected string $themeName = 'mixtest';
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        if (!is_dir(base_path('node_modules'))) {
+            $this->markTestSkipped('This test requires node_modules to be installed');
+        }
+
+        if (!is_file(base_path('node_modules/.bin/mix'))) {
+            $this->markTestSkipped('This test requires the mix package to be installed');
+        }
+
+        $this->originalThemesPath = Config::get('cms.themesPath');
+        Config::set('cms.themesPath', '/modules/cms/tests/fixtures/themes');
+
+        $this->themePath = base_path("modules/cms/tests/fixtures/themes/$this->themeName");
+
+        Config::set('cms.activeTheme', $this->themeName);
+
+        Event::flush('cms.theme.getActiveTheme');
+        Theme::resetCache();
+    }
+
+    protected function tearDown(): void
+    {
+        File::deleteDirectory("modules/cms/tests/fixtures/themes/$this->themeName/assets/dist");
+        File::delete("modules/cms/tests/fixtures/themes/$this->themeName/mix-manifest.json");
+
+        Config::set('cms.themesPath', $this->originalThemesPath);
+
+        parent::tearDown();
+    }
+
+    public function testGeneratesAssetUrl(): void
+    {
+        $theme = Theme::getActiveTheme();
+        $packageName = "theme-$this->themeName";
+
+        $this->artisan('mix:compile', [
+            $packageName,
+            '--manifest' => 'modules/cms/tests/fixtures/npm/package-mixtheme.json',
+            '--disable-tty' => true,
+        ])->assertExitCode(0);
+
+        $this->assertFileExists($theme->getPath($theme->getDirName() . '/mix-manifest.json'));
+
+        $controller = Controller::getController() ?: new Controller();
+
+        $extension = new Extension();
+        $extension->setController($controller);
+
+        $this->app->make('twig.environment')
+            ->addExtension($extension);
+
+        $contents = Twig::parse("{{ mix(['assets/dist/css/theme.css'], '$packageName') }}");
+
+        $this->assertStringContainsString('/assets/dist/css/theme.css?id=', $contents);
+    }
+}
diff --git a/modules/cms/twig/Extension.php b/modules/cms/twig/Extension.php
index 01ed17438e..0ad3c5fd45 100644
--- a/modules/cms/twig/Extension.php
+++ b/modules/cms/twig/Extension.php
@@ -3,6 +3,7 @@
 use Block;
 use Cms\Classes\Controller;
 use Event;
+use System\Classes\Asset\Mix;
 use System\Classes\Asset\Vite;
 use Twig\Extension\AbstractExtension as TwigExtension;
 use Twig\TwigFilter as TwigSimpleFilter;
@@ -53,6 +54,7 @@ public function getFunctions(): array
             new TwigSimpleFunction('component', [$this, 'componentFunction'], $options),
             new TwigSimpleFunction('placeholder', [$this, 'placeholderFunction'], ['is_safe' => ['html']]),
             new TwigSimpleFunction('vite', [$this, 'viteFunction'], $options),
+            new TwigSimpleFunction('mix', [$this, 'mixFunction'], $options),
         ];
     }
 
@@ -176,6 +178,11 @@ public function viteFunction(array $entrypoints, string $package): \Illuminate\S
         return Vite::tags($entrypoints, $package);
     }
 
+    public function mixFunction(array|string $paths, string $package, ?string $manifestPath = null): \Illuminate\Support\HtmlString|string
+    {
+        return Mix::tags($paths, $package, $manifestPath);
+    }
+
     /**
      * Opens a layout block.
      */
diff --git a/modules/system/classes/asset/Mix.php b/modules/system/classes/asset/Mix.php
new file mode 100644
index 0000000000..62c22c2cb8
--- /dev/null
+++ b/modules/system/classes/asset/Mix.php
@@ -0,0 +1,240 @@
+<?php
+
+namespace System\Classes\Asset;
+
+use Exception;
+use Illuminate\Support\Facades\App;
+use Illuminate\Support\HtmlString;
+use InvalidArgumentException;
+use Winter\Storm\Exception\SystemException;
+use Winter\Storm\Support\Collection;
+use Winter\Storm\Support\Facades\Url;
+use Winter\Storm\Support\Str;
+
+class Mix
+{
+    /**
+     * The preloaded assets.
+     *
+     * @var array
+     */
+    protected array $preloadedAssets = [];
+
+    /**
+     * The cached manifest files.
+     *
+     * @var array
+     */
+    protected static array $manifests = [];
+
+    /**
+     * Get the preloaded assets.
+     *
+     * @return array
+     */
+    public function preloadedAssets(): array
+    {
+        return $this->preloadedAssets;
+    }
+
+    /**
+     * Generate Mix tags for an entrypoint(s).
+     *
+     * @param array|string $entrypoints The list of entry points for Mix
+     * @param string|null $package The package name of the plugin or theme
+     * @param string|null $manifestPath
+     * @return HtmlString|string
+     * @throws SystemException
+     */
+    public function __invoke(array|string $entrypoints, ?string $package = null, ?string $manifestPath = null): HtmlString|string
+    {
+        if (!$package) {
+            throw new InvalidArgumentException('A package must be passed');
+        }
+
+        // Normalise the package name
+        $package = strtolower($package);
+
+        $manifestPath ??= 'mix-manifest.json';
+
+        if (!($compilableAssetPackage = PackageManager::instance()->getPackages('mix')[$package] ?? null)) {
+            throw new SystemException('Unable to resolve package: ' . $package);
+        }
+
+        $manifestPath = public_path($compilableAssetPackage['path'] . Str::start($manifestPath, '/'));
+
+        if (!isset(static::$manifests[$manifestPath])) {
+            if (!is_file($manifestPath)) {
+                throw new Exception("The Mix manifest does not exist.");
+            }
+
+            static::$manifests[$manifestPath] = json_decode(file_get_contents($manifestPath), true);
+        }
+
+        $manifest = static::$manifests[$manifestPath];
+
+        $entrypoints = collect($entrypoints)
+            ->map(fn($path) => Str::start($path, '/'));
+
+        $tags = collect();
+        $preloads = collect();
+
+        foreach ($entrypoints as $entrypoint) {
+            if (!isset($manifest[$entrypoint])) {
+                throw new Exception("Unable to locate file in Mix manifest: $entrypoint");
+            }
+
+            $preloads->push([
+                $entrypoint,
+                Url::asset($compilableAssetPackage['path'] . $manifest[$entrypoint]),
+            ]);
+
+            $tags->push($this->makeTagForEntrypoint($entrypoint, Url::asset($compilableAssetPackage['path'] . $manifest[$entrypoint])));
+        }
+
+        [$stylesheets, $scripts] = $tags->unique()->partition(fn($tag) => str_starts_with($tag, '<link'));
+
+        $preloads = $preloads->unique()
+            ->sortByDesc(fn($args) => $this->isCssPath($args[0]))
+            ->map(fn($args) => $this->makePreloadTagForEntrypoint(...$args));
+
+        return new HtmlString($preloads->join('') . $stylesheets->join('') . $scripts->join(''));
+    }
+
+    /**
+     * Helper method to generate Mix tags for an entrypoint(s).
+     *
+     * @param array|string $entrypoints The list of entry points for Mix
+     * @param string $package The package name of the plugin or theme
+     * @param string|null $manifestPath The relative path to the mix-manifest.json file from the package path
+     * @return HtmlString|string
+     *
+     * @throws SystemException
+     */
+    public static function tags(array|string $entrypoints, string $package, ?string $manifestPath = null): HtmlString|string
+    {
+        return App::make(static::class)($entrypoints, $package, $manifestPath);
+    }
+
+    /**
+     * Make a preload tag for the given entrypoint.
+     *
+     * @param $src
+     * @param $url
+     * @return string
+     */
+    protected function makePreloadTagForEntrypoint($src, $url): string
+    {
+        $attributes = $this->resolvePreloadTagAttributes($src, $url);
+
+        $this->preloadedAssets[$url] = $this->parseAttributes(
+            Collection::make($attributes)->forget('href')->all()
+        );
+
+        return '<link ' . implode(' ', $this->parseAttributes($attributes)) . ' />';
+    }
+
+    /**
+     * Make tag for the given entrypoint.
+     *
+     * @param $src
+     * @param $url
+     * @return string
+     */
+    protected function makeTagForEntrypoint($src, $url): string
+    {
+        return $this->makeTag($src, $url);
+    }
+
+    /**
+     * Generate an appropriate tag for the given URL.
+     *
+     * @param string $src
+     * @param string $url
+     * @return string
+     */
+    protected function makeTag(string $src, string $url): string
+    {
+        if ($this->isCssPath($src)) {
+            return $this->makeStylesheetTagWithAttributes($url, []);
+        }
+
+        return $this->makeScriptTagWithAttributes($url, []);
+    }
+
+    /**
+     * Generate a link tag with attributes for the given URL.
+     *
+     * @param string $url
+     * @param array $attributes
+     * @return string
+     */
+    protected function makeStylesheetTagWithAttributes(string $url, array $attributes): string
+    {
+        $attributes = $this->parseAttributes(array_merge([
+            'rel' => 'stylesheet',
+            'href' => $url,
+        ], $attributes));
+
+        return '<link ' . implode(' ', $attributes) . ' />';
+    }
+
+    /**
+     * Generate a script tag with attributes for the given URL.
+     *
+     * @param string $url
+     * @param array $attributes
+     * @return string
+     */
+    protected function makeScriptTagWithAttributes(string $url, array $attributes): string
+    {
+        $attributes = $this->parseAttributes(array_merge([
+            'src' => $url,
+        ], $attributes));
+
+        return '<script ' . implode(' ', $attributes) . '></script>';
+    }
+
+    /**
+     * Determines whether the given path is a CSS file.
+     *
+     * @param string $path
+     * @return bool
+     */
+    protected function isCssPath(string $path): bool
+    {
+        return Str::endsWith($path, '.css');
+    }
+
+    /**
+     * Resolve the attributes for the entrypoints generated preload tag.
+     *
+     * @param string $src
+     * @param string $url
+     * @return array
+     */
+    protected function resolvePreloadTagAttributes(string $src, string $url): array
+    {
+        return [
+            'rel' => 'preload',
+            'as' => $this->isCssPath($src) ? 'style' : 'script',
+            'href' => $url,
+        ];
+    }
+
+    /**
+     * Parse the attributes into key="value" strings.
+     *
+     * @param array $attributes
+     * @return array
+     */
+    protected function parseAttributes(array $attributes): array
+    {
+        return Collection::make($attributes)
+            ->reject(fn($value, $key) => in_array($value, [false, null], true))
+            ->flatMap(fn($value, $key) => $value === true ? [$key] : [$key => $value])
+            ->map(fn($value, $key) => is_int($key) ? $value : $key . '="' . $value . '"')
+            ->values()
+            ->all();
+    }
+}
diff --git a/modules/system/tests/classes/asset/MixTest.php b/modules/system/tests/classes/asset/MixTest.php
new file mode 100644
index 0000000000..e174f1a540
--- /dev/null
+++ b/modules/system/tests/classes/asset/MixTest.php
@@ -0,0 +1,168 @@
+<?php
+
+namespace System\Tests\Classes\Asset;
+
+use Cms\Classes\Theme;
+use Exception;
+use System\Classes\Asset\Mix;
+use System\Classes\Asset\PackageManager;
+use System\Tests\Bootstrap\TestCase;
+use Winter\Storm\Support\Facades\Config;
+use Winter\Storm\Support\Facades\Event;
+use Winter\Storm\Support\Facades\File;
+use Winter\Storm\Support\Facades\Url;
+
+class MixTest extends TestCase
+{
+    protected string $themePath;
+
+    protected string $originalThemesPath = '';
+
+    protected string $originalThemesPathLocal = '';
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        if (!is_dir(base_path('node_modules'))) {
+            $this->markTestSkipped('This test requires node_modules to be installed');
+        }
+
+        if (!is_file(base_path('node_modules/.bin/mix'))) {
+            $this->markTestSkipped('This test requires the mix package to be installed');
+        }
+
+        $this->originalThemesPath = Config::get('cms.themesPath');
+        Config::set('cms.themesPath', '/modules/system/tests/fixtures/themes');
+
+        $this->originalThemesPathLocal = Config::get('cms.themesPathLocal');
+        Config::set('cms.themesPathLocal', base_path('modules/system/tests/fixtures/themes'));
+        $this->app->setThemesPath(Config::get('cms.themesPathLocal'));
+
+        $this->themePath = base_path('modules/system/tests/fixtures/themes/mixtest');
+
+        Config::set('cms.activeTheme', 'mixtest');
+
+        Event::flush('cms.theme.getActiveTheme');
+        Theme::resetCache();
+    }
+
+    protected function tearDown(): void
+    {
+        File::deleteDirectory('modules/system/tests/fixtures/themes/mixtest/assets/dist');
+        File::delete('modules/system/tests/fixtures/themes/mixtest/mix-manifest.json');
+
+        Config::set('cms.themesPath', $this->originalThemesPath);
+
+        Config::set('cms.themesPathLocal', $this->originalThemesPathLocal);
+        $this->app->setThemesPath($this->originalThemesPathLocal);
+
+        parent::tearDown();
+    }
+
+    public function testThrowsExceptionWhenMixManifestIsMissing(): void
+    {
+        $this->expectException(Exception::class);
+        $this->expectExceptionMessage('The Mix manifest does not exist');
+
+        app(Mix::class)(['assets/dist/foo.css'], 'theme-mixtest');
+    }
+
+    public function testMixWithJsOnly(): void
+    {
+        $this->artisan('mix:compile', [
+            'theme-mixtest',
+            '--manifest' => 'modules/system/tests/fixtures/npm/package-mixtest.json',
+            '--disable-tty' => true,
+        ])->assertExitCode(0);
+
+        $package = PackageManager::instance()->getPackage('theme-mixtest')[0];
+
+        $manifest = json_decode(file_get_contents($package['path'].'/mix-manifest.json'), true);
+
+        $mixFileUrl = collect($manifest)->firstWhere(fn ($value, $key) => $key === '/assets/dist/js/theme.js');
+        $mixFileUrl = Url::asset($package['path'] . $mixFileUrl);
+
+        $result = app(Mix::class)(['assets/dist/js/theme.js'], 'theme-mixtest');
+
+        $this->assertStringEndsWith('<script src="'.$mixFileUrl.'"></script>', $result->toHtml());
+    }
+
+    public function testMixWithCssAndJs(): void
+    {
+        $this->artisan('mix:compile', [
+            'theme-mixtest',
+            '--manifest' => 'modules/system/tests/fixtures/npm/package-mixtest.json',
+            '--disable-tty' => true,
+        ])->assertExitCode(0);
+
+        $package = PackageManager::instance()->getPackage('theme-mixtest')[0];
+
+        $manifest = collect(json_decode(file_get_contents($package['path'].'/mix-manifest.json'), true))
+            ->map(fn ($value, $key) => Url::asset($package['path'].$value));
+
+        $result = app(Mix::class)(['assets/dist/css/theme.css', 'assets/dist/js/theme.js'], 'theme-mixtest');
+
+        $this->assertStringEndsWith(
+            '<link rel="stylesheet" href="'.$manifest['/assets/dist/css/theme.css'].'" />'
+            .'<script src="'.$manifest['/assets/dist/js/theme.js'].'"></script>',
+            $result->toHtml()
+        );
+    }
+
+    public function testThemeCanOverrideMixManifestPath(): void
+    {
+        Event::listen('cms.theme.extendConfig', function ($dirName, &$config) {
+            $config['mix_manifest_path'] = 'assets/dist';
+        });
+
+        $package = PackageManager::instance()->getPackage('theme-mixtest')[0];
+
+        rename(
+            $package['path'] . '/winter.mix.js',
+            $package['path'] . '/winter.mix.js.bak'
+        );
+
+        copy(
+            $package['path'] . '/winter.mix-manifest-override.js',
+            $package['path'] . '/winter.mix.js'
+        );
+
+        try {
+            $this->artisan('mix:compile', [
+                'theme-mixtest',
+                '--manifest' => 'modules/system/tests/fixtures/npm/package-mixtest.json',
+                '--disable-tty' => true,
+            ])->assertExitCode(0);
+
+            $this->assertFileExists($package['path'] . '/assets/dist/mix-manifest.json');
+
+            $manifest = json_decode(file_get_contents($package['path'] . '/assets/dist/mix-manifest.json'), true);
+
+            foreach ($manifest as $key => $value) {
+                $this->assertStringContainsString($key, (string) app(Mix::class)($key, 'theme-mixtest', 'assets/dist/mix-manifest.json'));
+            }
+        } catch (Exception $e) {
+            throw $e;
+        } finally {
+            rename(
+                $package['path'] . '/winter.mix.js.bak',
+                $package['path'] . '/winter.mix.js'
+            );
+        }
+    }
+
+    public function testThrowsAnExceptionForInvalidMixFile()
+    {
+        $this->expectException(Exception::class);
+        $this->expectExceptionMessage('Unable to locate file in Mix manifest: /assets/dist/foo.css');
+
+        $this->artisan('mix:compile', [
+            'theme-mixtest',
+            '--manifest' => 'modules/system/tests/fixtures/npm/package-mixtest.json',
+            '--disable-tty' => true,
+        ])->assertExitCode(0);
+
+        app(Mix::class)('assets/dist/foo.css', 'theme-mixtest');
+    }
+}
diff --git a/modules/system/tests/fixtures/npm/package-mixtest.json b/modules/system/tests/fixtures/npm/package-mixtest.json
new file mode 100644
index 0000000000..e38736ed5c
--- /dev/null
+++ b/modules/system/tests/fixtures/npm/package-mixtest.json
@@ -0,0 +1,11 @@
+{
+    "type": "module",
+    "workspaces": {
+        "packages": [
+            "modules/system/tests/fixtures/themes/mixtest"
+        ]
+    },
+    "devDependencies": {
+        "laravel-mix": "^6.0.41"
+    }
+}
diff --git a/modules/system/tests/fixtures/themes/mixtest/assets/src/css/theme.css b/modules/system/tests/fixtures/themes/mixtest/assets/src/css/theme.css
new file mode 100644
index 0000000000..d224431f16
--- /dev/null
+++ b/modules/system/tests/fixtures/themes/mixtest/assets/src/css/theme.css
@@ -0,0 +1,3 @@
+h1 {
+    color: red;
+}
diff --git a/modules/system/tests/fixtures/themes/mixtest/assets/src/js/theme.js b/modules/system/tests/fixtures/themes/mixtest/assets/src/js/theme.js
new file mode 100644
index 0000000000..55fb7c1a0a
--- /dev/null
+++ b/modules/system/tests/fixtures/themes/mixtest/assets/src/js/theme.js
@@ -0,0 +1 @@
+window.alert('hello world');
diff --git a/modules/system/tests/fixtures/themes/mixtest/theme.yaml b/modules/system/tests/fixtures/themes/mixtest/theme.yaml
new file mode 100644
index 0000000000..cfd8ec5e95
--- /dev/null
+++ b/modules/system/tests/fixtures/themes/mixtest/theme.yaml
@@ -0,0 +1 @@
+name: 'Mix Test'
diff --git a/modules/system/tests/fixtures/themes/mixtest/winter.mix-manifest-override.js b/modules/system/tests/fixtures/themes/mixtest/winter.mix-manifest-override.js
new file mode 100644
index 0000000000..89bb436123
--- /dev/null
+++ b/modules/system/tests/fixtures/themes/mixtest/winter.mix-manifest-override.js
@@ -0,0 +1,13 @@
+const mix = require('laravel-mix');
+
+mix.setPublicPath(__dirname)
+    .options({
+        manifest: 'assets/dist/mix-manifest.json',
+    })
+    .version()
+
+    // Render Tailwind style
+    .postCss('assets/src/css/theme.css', 'assets/dist/css/theme.css')
+
+    // Compile JS
+    .js('assets/src/js/theme.js', 'assets/dist/js/theme.js');
diff --git a/modules/system/tests/fixtures/themes/mixtest/winter.mix.js b/modules/system/tests/fixtures/themes/mixtest/winter.mix.js
new file mode 100644
index 0000000000..7affd67545
--- /dev/null
+++ b/modules/system/tests/fixtures/themes/mixtest/winter.mix.js
@@ -0,0 +1,13 @@
+const mix = require('laravel-mix');
+
+mix.setPublicPath(__dirname)
+    .options({
+        manifest: true,
+    })
+    .version()
+
+    // Render Tailwind style
+    .postCss('assets/src/css/theme.css', 'assets/dist/css/theme.css')
+
+    // Compile JS
+    .js('assets/src/js/theme.js', 'assets/dist/js/theme.js');