crystal-vite v0.1.0

Integrate Crystal applications with Vite for asset management and HMR

vite

Vite integration for Crystal. Reads a manifest.json produced by vite build and emits the <script>, <link rel="stylesheet">, and <link rel="modulepreload"> tags your entrypoints need — with a dev mode that points at the Vite dev server instead, including React Fast Refresh.

Requirements

  • Crystal >= 1.21.0
  • Vite 5 or newer

Installation

  1. Add the dependency to your shard.yml:

    dependencies:
      vite:
        github: codedByShoe/crystal-vite
        version: ~> 0.1.0
    
  2. Run shards install

Usage

require "vite"

config = Vite::Config.new
config.dev = ENV["APP_ENV"]? != "production"

client = Vite::Client.new(config)

client.tags("src/main.ts")

In production that resolves the manifest and emits stylesheets, then modulepreloads, then the entry script:

<link rel="stylesheet" href="/build/assets/shared-aKvd9S2.css" />
<link rel="stylesheet" href="/build/assets/main-r2MXTfSO.css" />
<link rel="modulepreload" href="/build/assets/shared-DkKv66kD.js" />
<script type="module" src="/build/assets/main-B86osw1_.js"></script>

In dev mode the manifest is ignored entirely — nothing needs to be built — and the tags point at the dev server:

<script type="module" src="http://localhost:5173/@vite/client"></script>
<script type="module" src="http://localhost:5173/src/main.ts"></script>

tags accepts multiple entries, and has an IO overload that avoids building an intermediate string:

client.tags("src/main.ts", "src/admin.ts")
client.tags(io, "src/main.ts")

React

Setting react prepends the React Refresh preamble, which must run before the Vite client loads:

config.dev = true
config.react = true

client.tags("src/main.tsx")

Individual assets

client.asset("src/main.ts")  # => "/build/assets/main-DOZrsiw7.js"
client.css("src/main.ts")    # => ["/build/assets/shared-aKvd9S2.css", ...]

css resolves stylesheets transitively through the import graph, so CSS carried by a shared chunk is included. Stylesheets behind a dynamic import are excluded, since Vite loads those at runtime with the chunk. Circular import graphs are handled — Vite output does produce them.

Vite gives every emitted asset its own manifest entry, so asset works for images and fonts too, keyed by source path:

client.asset("src/logo.svg")  # => "/build/assets/logo-BWN8Kq6B.svg"

assets lists everything an entry pulls in, resolved transitively the same way css is:

client.assets("src/main.ts")
# => ["/build/assets/logo-BWN8Kq6B.svg", "/build/assets/texture-BvTte26A.svg"]

Configuration

Property Default Purpose
dev false Emit dev-server tags and skip the manifest entirely
react false Prepend the React Refresh preamble in dev
build_dir "build" Public-facing URL prefix for built assets
public_dir "public" Document root, used to locate the manifest on disk
dev_server_url "http://localhost:5173" Vite dev server origin
asset_host nil CDN origin; when set, replaces /<build_dir> in asset URLs
manifest_path nil Explicit manifest path, overriding the default location

The manifest defaults to public/build/.vite/manifest.json — i.e. File.join(public_dir, build_dir, ".vite", "manifest.json"). Point your Vite config's build.outDir at public/build to match, or set manifest_path directly. See Kemal example below

With asset_host set, URLs are rewritten to that origin:

config.asset_host = "https://cdn.example.com"
client.asset("src/main.ts")  # => "https://cdn.example.com/assets/main-B86osw1_.js"

With Kemal

Kemal serves static files from ./public by default, so there is nothing else to configure for use of this package with Kemal. Just make sure to point Vite's build.outDir at public/build.

# src/app.cr
require "kemal"
require "vite"

VITE = Vite::Client.new(Vite::Config.new.tap { |c|
  c.dev = Kemal.config.env != "production"
  c.react = true
})

get "/" do |env|
  render "views/index.ecr", "views/layout.ecr"
end

Kemal.run
<%# views/layout.ecr %>
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <%= VITE.tags("src/main.tsx") %>
  </head>
  <body><%= content %></body>
</html>

Example Vite config:

// vite.config.js
import { defineConfig } from "vite";

export default defineConfig({
  // Make sure to set publicDir to false. Let Kemal control public/ instead of Vite
  publicDir: false,
  build: {
    manifest: true,
    outDir: "public/build",
    rollupOptions: { input: "src/main.tsx" },
  },
});

Run vite dev alongside Kemal in development, and vite build before deploying.

Errors

  • Vite::ManifestError — manifest missing, unparseable, or referencing a chunk that isn't in the manifest.
  • Vite::EntryNotFoundError — the requested entry isn't in the manifest. The message lists the entries that are, which is usually enough to spot the typo.

Both inherit from Vite::Error.

Development

Run the specs:

crystal spec

Specs run against spec/fixtures/vite7-react.json, which is recorded from vite build. Regenerate it from examples/react when bumping Vite support:

cd examples/react
npm install
npm run record

Commit the resulting diff — a change in that file, or in the VITE_VERSION beside it, is the signal that Vite's manifest format moved.

The only manifests built inline in specs are malformed ones — dangling imports, bad JSON — which a build cannot produce.

The example app is never built by crystal spec and Node is not part of CI.

Contributing

  1. Fork it (https://github.com/codedByShoe/crystal-vite/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Contributors

Repository

crystal-vite

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 0
  • about 2 hours ago
  • August 16, 2026
License

MIT License

Links
Synced at

Mon, 17 Aug 2026 01:35:29 GMT

Languages