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/.
Directory layoutapp/shared/assets/ favicons/favicon.svg products/quad-lineup.jpg
In markup, write a path
app/pages/home/template.ehtml<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:
Output<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:
Example.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:
Exampleimport 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:
Exampleimport 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 }, ];
Template<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:
Importimport 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:
Not asset paths<a href="#pricing">Pricing</a> <!-- an anchor, untouched --> <img src="#app/shared/assets/logo.svg"> <!-- NOT an asset path -->
Example.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:
Bare importimport "#app/shared/assets/logo.svg"; // no effect
Only .css does something useful bare. It links the stylesheet:
Stylesheet importimport "./style.css";
Web fonts
Preload the faces the first screen sets type in with a <link rel="preload">
in the page. Elements hashes font URLs like any other asset and leaves the
preload to the page, because only the page knows which faces it paints: a
@font-face is a declaration, not a fetch, and unicode-range makes it
conditional. Stylesheets are preloaded for you, since the browser cannot see an
@imported stylesheet until its parent has downloaded.
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:
Font importimport interRoman from "#app/shared/styles/fonts/Inter.subset.woff2";
Preload<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, serve the file from a route. The
bytes live in a Postgres bytea column, as below, or in S3 or any object store
with the row holding the key; the route serves either the same way.
Store the file, with its content type and a hash you can cache on:
Schemacreate 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.tsimport { 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.tsapp.route("/photos/:id", servePhoto);
Then the URL is ordinary data:
Template<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.