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.gitgit branch -M main && git push -u origin mainThe 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 pushYou 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.jsonYet 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=42Notice 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.svelteCreate 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.svelteTo 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 buildnpm run preview -- --opendev Modenpm run dev -- --opennpm run build:vercel304 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 BYPASSInitially 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-ModifiedIf-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.