crystal-vite v0.1.0
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
-
Add the dependency to your
shard.yml:dependencies: vite: github: codedByShoe/crystal-vite version: ~> 0.1.0 -
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
- Fork it (https://github.com/codedByShoe/crystal-vite/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Andrew Shoemaker - creator and maintainer
crystal-vite
- 0
- 0
- 0
- 0
- 0
- about 2 hours ago
- August 16, 2026
MIT License
Mon, 17 Aug 2026 01:35:29 GMT