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";
Web fonts
Elements emits fonts and hashes their URLs like any other asset, and it does
not preload them. Stylesheets are preloaded automatically because @import is
serial per level and the browser cannot see an interior stylesheet until its
parent has downloaded. Fonts are different: a @font-face is a declaration,
not a fetch, and unicode-range makes it conditional. Only the page knows
which faces it paints.
So write the preload yourself, for the faces the first screen sets type in.
Import the font so the hint points at the same hashed URL the stylesheet asks
for, and keep crossorigin on it. Fonts are fetched in CORS mode even
same-origin, and a preload without it downloads the file a second time:
import interRoman from "#app/shared/styles/fonts/Inter.subset.woff2";
<link rel="preload" href={interRoman} as="font" type="font/woff2" crossorigin="anonymous" />
Faces you do not preload still work. They are fetched when a rule needs them,
which is what you want for an italic used below the fold or a full face sitting
behind a unicode-range miss.
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.