This project demonstrates how you might configure Vercel to generate optimized images for SvelteKit.
If you haven't got a SvelteKit app already deployed to Vercel, follow these steps...
npm create svelte@latest svelte-vercel-optimized-images
Need to install the following packages:
[email protected]
Ok to proceed? (y) y
create-svelte version 6.0.6
┌ Welcome to SvelteKit!
│
◇ Which Svelte app template?
│ Skeleton project
│
◇ Add type checking with TypeScript?
│ No
│
◇ Select additional options (use arrow keys/space bar)
│ Add Prettier for code formatting
│
└ Your project is ready!
✔ Prettier
https://prettier.io/docs/en/options.html
https://github.com/sveltejs/prettier-plugin-svelte#options
Install community-maintained integrations:
https://github.com/svelte-add/svelte-add
Next steps:
1: cd svelte-vercel-optimized-images
2: npm install
3: git init && git add -A && git commit -m "Initial commit" (optional)
4: npm run dev -- --open
To close the dev server, hit Ctrl-C
Stuck? Visit us at https://svelte.dev/chat
cd svelte-vercel-optimized-images
npm install
git init && git add -A && git commit -m 'init'
git remote add origin https://github.com/<username>/svelte-vercel-optimized-images.git
git branch -M main && git push -u origin main
The generated SvelteKit app is now pushed to a GitHub repository and deployed to Vercel.
The next step is to add some images to the app that can later be optimized by Vercel.
Go to https://placekitten.com and right-click download any three cat images.
Save to the project under the ./static
directory and name them cat1.jpeg
, cat2.jpeg
, cat3.jpeg
.
Edit ./src/routes/+page.svelte
to add an img
tag for /cat1.jpeg
and import ./styles.css
:
<!-- +page.svelte -->
<script>
import './styles.css';
</script>
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
<p>
<img src="/cat1.jpeg" alt="Cat One" />
</p>
Create ./src/routes/styles.css
with code:
/* styles.css */
h1 {
background-image: url(/cat2.jpeg);
}
Preview the changes locally using npm run build && npm run preview -- --open
.
There should be a repeating cat image behind the H1 text, and another cat image below the welcome paragraph.
Push to GitHub to trigger and a new Vercel build with the added images:
git add -A && git commit -m 'add unoptimized cat images' && git push
You should see the same app as local on a domain like https://svelte-vercel-optimized-images.vercel.app
Vercel will only generate optimized images if configured to do so in .vercel/output/config.json
.
For this we will add a new build command that calls a custom script to modify the Vercel configuration JSON generated by vite build
.
First, edit package.json
and add to the scripts
section after the build
entry the following:
"build:vercel": "PUBLIC_BUILD_VERCEL=true vite build && node scripts/add-optimized-images-to-vercel-output-config",
Vercel doesn't know to run our new build:vercel
command, so visit the Vercel dashboard for the project and go to "Settings". Under "Build and Output Settings" > "Build Command" select OVERRIDE and change the build command to npm run build:vercel
and save.
This new build:vercel
run script expects to call ./scripts/add-optimized-images-to-vercel-output-config.js
.
Create a scripts
directory and file add-optimized-images-to-vercel-output-config.js
with contents:
// add-optimized-images-to-vercel-output-config.js
import fs from 'node:fs';
const config_file = '.vercel/output/config.json';
const config = JSON.parse(fs.readFileSync(config_file, 'utf8'));
config.images = {
sizes: [640, 960, 1280],
domains: ['svelte-vercel-optimized-images.vercel.app'], // YOUR DOMAIN HERE
formats: ['image/avif', 'image/webp'],
minimumCacheTTL: 300
};
fs.writeFileSync(config_file, JSON.stringify(config, null, '\t'));
You will need to change the svelte-vercel-optimized-images.vercel.app
domain to match what you named your app/project.
This configuration modification tells Vercel to generate optimized images at sizes 640, 960, 1280
with file formats 'image/avif', 'image/webp'
. Next, we will specify and utilize these generated images in an srcset
within our app.
It is now possible to pass the configuration to the Vercel adapter as documented in the SvelteKit docs.
To do this, make the adapter usage in svelte.config.js
look something like that:
/// file: svelte.config.js
import adapter from '@sveltejs/adapter-vercel';
export default {
kit: {
adapter({
images: {
sizes: [640, 828, 1200, 1920, 3840],
formats: ['image/avif', 'image/webp'],
minimumCacheTTL: 300,
domains: ['example-app.vercel.app'],
}
})
}
};
To use the Vercel adapter, you must install the underlying adapter.
This is probably a better option as it allows us to not have to make the configuration modification script outlined above, and we can remove the && node scripts/add-optimized-images-to-vercel-output-config
added to the build command in package.json
.
vercel.json
Yet another option would be to create a vercel.json
in the root of the project repository that will be picked up by Vercel upon deploy.
You could pass in your image settings this way but since the adapter provides an API might as well just use that.
Note that the above other two methods have the benefit that the sizes
settings could be passed in as a variable, which would be an improvement overall as we would no longer be hard-coding these values across mutliple files.
While not covered here, that would be a good improvement to make. Then just save using vercel.json
for any other settings you might want to have, such as Cache-Control
headers
for certain paths/files, etc.
The optimized images will be available behind a special URL provided by Vercel which looks something like:
<your-app-name>.vercel.app/_vercel/image?url=%2Fcat1.jpeg&w=1280&q=42
Notice you can additionally specify the size and quality like &w=1280&q=42
which we will make use of for srcset
(srcset
on MDN).
Additionally, we will use the PUBLIC_BUILD_VERCEL=true
environment variable we set as part of the vercel:build
npm run script to not have broken images when previewing the app locally.
Image.svelte
Create a new folder under ./lib
called components
and a new file under that called Image.svelte
.
In this new file (./src/lib/components/Image.svelte
) paste the following:
<!-- Image.svelte -->
<script lang="ts">
import { srcset } from '$lib/vercel-image';
export let src;
export let alt;
export let quality;
export let width = '';
export let height = '';
export let lazy = true;
</script>
<img
srcset={srcset(src, undefined, quality)}
sizes="
(max-width: 640px) 640px,
(max-width: 960px) 960px,
1280px
"
{alt}
{width}
{height}
loading={lazy ? 'lazy' : 'eager'}
/>
Looking closely you will see this produces <img srcset=... sizes=... loading=... />
and uses a function imported from a file $lib/vercel-image
. We need to create $lib/vercel-image
by making a new file at ./src/lib/vercel-image.js
with contents:
// vercel-image.js
import { dev } from '$app/environment';
import { PUBLIC_BUILD_VERCEL } from '$env/static/public';
export function srcset(src, widths = [640, 960, 1280], quality = 90) {
if (dev || PUBLIC_BUILD_VERCEL !== 'true') return src;
return widths
.slice()
.sort((a, b) => a - b)
.map((width, i) => {
const url = `/_vercel/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality}`;
const descriptor = ` ${width}w`;
return url + descriptor;
})
.join(', ');
}
Notice the if (dev || PUBLIC_BUILD_VERCEL !== 'true') return src;
line which shortcircuits producing an srcset
locally.
The exported srcset
function will, for hard-coded sizes [640, 960, 1280]
with default quality 90
, generate an srcset
like:
srcset=" /_vercel/image?url=%2Fcat1.jpeg&w=640&q=90 640w,
/_vercel/image?url=%2Fcat1.jpeg&w=960&q=90 960w,
/_vercel/image?url=%2Fcat1.jpeg&w=1280&q=90 1280w"
Putting this all together, our Image
component results in an <img />
element like:
<img
alt="Third photo"
srcset="
/_vercel/image?url=%2Fcat1.jpeg&w=640&q=90 640w,
/_vercel/image?url=%2Fcat1.jpeg&w=960&q=90 960w,
/_vercel/image?url=%2Fcat1.jpeg&w=1280&q=90 1280w
"
sizes="
(max-width: 640px) 640px,
(max-width: 960px) 960px,
1280px
"
loading="lazy"
/>
Image.svelte
To use it, edit +page.svelte
and in the <script>
tag add:
<script>
import './styles.css';
import Image from '$lib/components/Image.svelte'; // IMPORT CUSTOM COMPONENT
</script>
Then where we had <img src="/cat1.jpeg" alt="Cat One" />
replace that with:
<Image src="/cat1.jpeg" alt="Cat One" quality={42} />
Notice the forward slash before the /cat1.jpeg
src attribute; this will be automatically changed to point to /_vercel/image?url=...
by our srcset
function in ./lib/vercel-image.js
.
When not PUBLIC_BUILD_VERCEL
, this will simply point to our static /cat1.jpeg
image.
PUBLIC_BUILD_VERCEL
in CSSWe can also use the vercel optimized image using this pattern in the CSS stylesheet:
/* style.css */
h1 {
background-image: url(/cat1.jpeg);
}
.vercel-build h1 {
background-image: url(/_vercel/image?url=%2Fcat1.jpeg&w=1280&q=80);
}
Yes, you do have to type /_vercel/image?url=...
in a .vercel-build
override CSS class, as needed, when doing CSS. This could perhaps be automated out in a processing step.
In order for this CSS to work, we need to have .vercel-build
injected onto some HTML element that wraps our app. One way we can do this is to wrap the existing +page.svelte
HTML in a div
with dynamic attribute of class:vercel-build
like so:
<!-- +page.svelte -->
<script>
import './styles.css';
import Image from '$lib/components/Image.svelte';
import { PUBLIC_BUILD_VERCEL } from '$env/static/public';
</script>
<div class="app" class:vercel-build={PUBLIC_BUILD_VERCEL === 'true'}>
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
<p>
<Image src="/cat1.jpeg" alt="Third photo" quality={42} />
</p>
</div>
Notice the new import and addition of class:vercel-build={PUBLIC_BUILD_VERCEL === 'true'}
.
To test if the optimized images are being generated by Vercel and utilized in our app, commit all changes and push to GitHub. Vercel should use the updated build command build:vercel
when building and deploying.
Right click to inspect the cat images and see they are using srcset
pointing to /_vercel/image?url=...
like:
https://svelte-vercel-optimized-images.vercel.app/_vercel/image?url=%2Fcat1.jpeg&w=1280&q=42
You can also check the Network tab in the developer tools to see that the images are being served as AVIF:
npm run build
npm run preview -- --open
dev
Modenpm run dev -- --open
npm run build:vercel
304
Caching WorksI noticed a difference in the response from optimized vs non-optimized links, and that lead me down the path of questioning how caching works with the whole 304
response strategy, and if that can be changed.
Here are some notes to self that may be useful for anyone else not familiar with how the 304
caching works on Vercel, or how to modify it so that the browser caches in a more traditional/familiar manner, and then falls back to making that network request.
<img src="/_vercel/image?url=%2Fsvg%2Frefs%2Fcard.svg&w=300&q=100">
NOTICE: Responds with Last-Modified
and HIT
.
<img src="/svg/refs/card.svg">
NOTICE: Responds with Etag
and BYPASS
.
HIT
vs BYPASS
Initially I was worried BYPASS
instead of HIT
for X-Vercel-Cache
on the "non-optimized" SVG meant the cache at the edge was bypassed, but the following proves the CDN is still being used in both cases:
Because these 304
responses are less than the actual non-cached file size of 1.3 kB
, I have learned this means that Vercel is telling the browser the asset has not changed, and so the browser should go ahead and load the requested unchanged asset from its cache instead of re-downloading an updated version of the file.
From what I understand, observing the file size difference and 304
status is the best way to confirm that caching is working when the strategy is this 304
call-and-response, where the browser asks the server if its cache is still valid.
If-None-Match
/Etag
vs If-Modified-Since
/Last-Modified
If-None-Match
/Etag
)I have learned that for "non-optimized" links like *.vercel.app/svg/card.svg
, the request is sent with:
If-None-Match: W/"f38ba94d109a22a7b5f722a607dc0a6a"
This is an Etag
that Vercel will use to decide if it should send a 304
, instead of 200
with the full file.
This is on a per-file basis; re-deploy to Vercel does not result in 200
for the asset unless the asset was itself modified as part of the re-deploy.
When it is a 304
the browser devtools will say "not from cache" when inspecting the 304
response in Network. This can be confusing at first, as the asset loads on the site so it must come from somewhere despite the browser saying this request was NOT served from a cache. There is obviously an underlying fact that the image is stored and pulled from a cache somewhere in the browser. It seems if you see an Etag
was sent as part of the request header you can know that value is coming from an asset stored in the browser cache.
For this "non-optimized" request, I can see that X-Vercel-Cache: BYPASS
is in the response, which is concerning if we are wanting be sure the Vercel edge cache is being used to serve the file. However if we do a hard reload, we get a 200 OK
response and this time it is X-Vercel-Cache: HIT
so it looks like BYPASS
only happens when it is a 304
which probably is just an internal implementation difference compared to "optimized" image request.
If-Modified-Since
/Last-Modified
)I have learned that for "optimized" links like *.vercel.app/_vercel/image?url=%2Fsvg%2Fcard.svg&w=300&q=100
, the request is sent with:
If-Modified-Since: Thu, 15 Feb 2024 12:26:16 GMT
And so the strategy to check for file difference here is to use a date check instead of Etag
.
The response regardless of 304
or 200
always contains X-Vercel-Cache: HIT
, which is a bit different when compared to the "non-optimized" response which is HIT
for 200
and BYPASS
for 304
.
304
Network RequestsThe If-Modified-Since header is used to specify the time at which the browser last received the requested resource. The If-None-Match header is used to specify the entity tag that the server issued with the requested resource when it was last received.
So, the browser stores which ever strategy (If-None-Match
vs If-Modified-Since
) it received originally with the initial cached file. Then, for future requests of that file, sends the Etag
or Last-Modified
value along to the Vercel CDN which can then reply with an 304
if the file has not changed.
But, what if we know a cached file like an SVG in this case will never change? Perhaps that SVG is referenced in multiple places (like an SVG sprite) and thus would incur multiple network requests and 304 responses...
In this case, we may want to tell visiting browsers to always just load from the local browser cache instead of pinging the Vercel CDN. That can be done by adding some configuration like the following to vercel.json
:
{
"routes": [
{
"src": "/svg/.+",
"headers": {
"cache-control": "public, immutable, max-age=31536000"
}
}
]
}
Note that a vercel.json
in the root of the project does still get picked up by Vercel, and these setting will be taken in addition to the build output config that is generated by the Vercel adapter.
Now, when inspecting the Network requests for files under /svg
, it will say (disk cache)
for Size
and 200
for Status
instead of ~70 B
and 304
.
Make sure this configuration is added before any catch-all route, otherwise it will get swallowed, and not work.
I can confirm that the edge cache is sending 304
for unmodified assets, and the browser does not re-download the asset file over the network. Furthermore, it is possible to tell Vercel CDN to send Cache-Control
headers for certain requests, which allows me to tell the browser to cache the file locally and avoid the 304
roundtrip. Finally, seeing BYPASS
for X-Vercel-Cache
on a 304
response is not something to be worried about.