Assets
elements man assets Read as markdownImages, fonts, PDFs, downloads. Any file your app ships that is not code.
There are two kinds, and they are not the same problem:
- Static assets live in your source tree. You know their names when you
write the code. They are content-hashed at build time and served from
/assets/. - Dynamic assets come from the database: a product photo, an avatar, an upload. You do not know the filename until the row is read, so a build-time URL cannot exist. These are served by a route.
Static assets
Put them anywhere under app/. The scaffold uses app/shared/assets/.
app/shared/assets/
favicons/favicon.svg
products/quad-lineup.jpg
In markup, write a path
<img src="./hero.jpg" alt="">
<link rel="icon" href="../../shared/assets/favicons/favicon.svg">
Relative paths only. The build rewrites the attribute to the file's content-hashed URL:
<img src="/assets/app/pages/home/hero.a17f3c0b9d2e4a51.jpg" alt="">
This works on src, href, poster and data, and on the content of the
meta tags that hold URLs (og:image, twitter:image, and the rest of that
family). content is a generic attribute name, so the URL-bearing properties
are a fixed list rather than anything inferred.
It also works in CSS:
.hero { background-image: url("../assets/products/quad-lineup.jpg"); }
In code, import it
An import of a non-code file gives you its URL as a string:
import favicon from "#app/shared/assets/favicons/favicon.svg";
favicon; // "/assets/app/shared/assets/favicons/favicon.f9e9effe4ae761bd.svg"
An import is the only way to get an asset URL into a value. You need one the moment the asset is part of data rather than one fixed spot in markup:
import quad from "#app/shared/assets/products/quad.jpg";
import sidecar from "#app/shared/assets/products/sidecar.jpg";
const CASES = [
{ name: "Quad", photo: quad },
{ name: "Sidecar", photo: sidecar },
];
<li e:for={c of CASES}>
<img src={c.photo} alt={c.name}>
<span>{c.name}</span>
</li>
The URL is inlined at build time, so there is no runtime lookup and no helper to call. An imported asset and a written path produce the identical URL. They are one mechanism with two entry points.
Importing an asset makes it servable. That holds from server-only code too, so a route that builds a webmanifest or an rss feed can import an icon and get a real URL, and it holds even when nothing routes to the importing file: the import is the reference, not the reachability of the code around it.
Which to use: a path when the file sits next to the template that uses it,
an import for anything shared or anything that has to travel as a value. The
scaffold imports its favicon because app/shared/assets/ is nowhere near
app/pages/home/.
# is for imports, not for URLs
#app/... is import-map syntax. It belongs in an import statement:
import favicon from "#app/shared/assets/favicons/favicon.svg";
In an attribute or a CSS url(), # means what it means in those languages.
A page anchor, an SVG fragment. It is left alone:
<a href="#pricing">Pricing</a> <!-- an anchor, untouched -->
<img src="#app/shared/assets/logo.svg"> <!-- NOT an asset path -->
.icon { fill: url(#gradient); } /* an SVG paint server */
Bare imports
A clause-less import of an asset binds nothing, so there is nothing to use and the statement is dropped:
import "#app/shared/assets/logo.svg"; // no effect
Only .css does something useful bare. It links the stylesheet:
import "./style.css";
Dynamic assets
When the filename lives in a database row, no build-time URL exists. Store the bytes and serve them from a route.
Store the file, with its content type and a hash you can cache on:
create table photos (
id uuid primary key default uuidGenerateV7(),
bytes bytea not null,
mimeType text not null,
hash text not null
);
Serve them from a route. A route that returns bytes is not a page, so it lives
in app/routes/:
// app/routes/photos.ts
import { Request, Response, sql } from "@elements/app";
interface Photo {
bytes: Buffer;
mimeType: string;
}
export default function servePhoto(req: Request, res: Response) {
let id = req.params.id;
let photo = sql<Photo>(`
select bytes, mimeType from photos where id = ${id}::uuid
`).first();
if (!photo) {
res.status(404);
return "";
}
res.setHeader("Content-Type", photo.mimeType);
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
return photo.bytes;
}
// index.ts
app.route("/photos/:id", servePhoto);
Then the URL is ordinary data:
<img src={`/photos/${row.photoId}?v=${row.hash}`} alt={row.name}>
The ?v=<hash> is what makes immutable safe: the URL changes when the bytes
change, so a browser can hold the old one forever without ever showing stale
content. That is the same trick the static pipeline plays with a hashed
filename.
For uploads, accepting the file in the first place, see
elements man recipes/file-upload.