Converting my static site to use SvelteKit and Svelte 5
Runes used in this project:
$props()
$derived()
Svelte 5 is now in the release candidate stage. One of its major updates is that it introduces Runes, which are change to how it had previously handled reactivity (e.g. state). I'm new to Svelte, so I don't want to attempt to go into all the intricacies, but there are good few resources about it out there (such as this episode of the Syntax Podcast).
In this article, I want to go over converting a static HTML and CSS site to use SvelteKit and Svelte 5. Even though the site itself only uses HTML and CSS, there were a few gotchas throughout. The site I am remaking is called "Cork Car Cut", a fictional initiative for the Cork City government that encourages the people of Cork to choose car-alternatives for transport. Here are the repos and the deployed versions of the site:
Deployed site - Original Cork Car Cut
Repo - Svelte version of Cork Car Cut
Deployed site - Svelte version of Cork Car Cut
Installation
This isn't a post about installing and getting set up, so if you get some errors and need to install them, you may need to do some Googling (or ask ChatGPT). You should be able to install Svelte and get everything running with this command:
npm create svelte@latest your-app-name cd your-app-name npm install npm run dev
In the setup wizard, you select the options by using the spacebar, then you finalise by pressing Enter. The only thing you really need here is to use Svelte 5.
Converting static pages
When your SvelteKit app has been set up, you will have a number of folders. The most important ones for this project are the src
folder (which contains the important routes
and lib
folders) and the static
folder.
The first thing I did was create a +layout.svelte
file in the routes
folder. All files in the routes
folder start with +
. The code in the +layout.svelte
file will appear in every page in the routes folder. With that in mind, I copied the code from my index.html file of the original site to the +layout.svelte
file in my routes
folder, but only left the header and footer code.
The +layout.svelte
file still didn't look right - it still had all the head
information and the opening and closing body
tags. I know that Svelte uses Components, so all the header and footer code would be going into those eventually. That would mean that the body
tag would be getting split and would lead to syntax errors. That made me realise that I would be best off moving all the code to until (and including) the opening <body>
tag and all the code (and including) after the closing </body>
tag to another file in the src
folder - the app.html
file. When generated, that file already contains some placeholders where Svelte code is rendered.
With the rest of the main
code, I created a new file called +page.svelte
, which is a sibling to the +layout.svelte
file I created. Since it is in the root the route
folder, it will display the contents of the index.html
page.
After I added the contents to my +page.svelte
, I was curious to see how it looked. However, when I checked, I only my header and footer were displayed (still with no styles). It turns out that I needed to tell the +layout.svelte
file to display all the contents of all the files that would inherit from it (its "children"). To do that, I finally stepped away from solely looking at HTML - I had to create a <script>
tag above my HTML. As it turns out, this is the typical layout of a Svelte file:
<script> Your JavaScript goes here </script> <div>Your HTML goes here</div> <style> Your styles go here </style>
In order to make the content of the pages appear, I got to use my first Rune - $props. $props
, as you can imagine, outputs the properties that have been passed to a page, layout or component. In the case of the +layout.svelte
file, Svelte provides one out of the box - children
. In the script I added at the top of the file, it is typical to use destructuring to assign the value from the pass props. It looks like this:
<script> let { children } = $props(); </script>
Once I assigned the props, I had to output them. In Svelte 5, the way to do that is to use Snippets. Once again, Svelte offers an out-of-the-box way to render children. In the HTML part of my +layout.svelte
file, I had to add {@render children()}
like this:
<main> {@render children()} </main>
Here's a screenshot of how it looked:
With initial layout and page set up, I then decided to move the header and footer into their own files. I made a new components
folder inside of the lib
folder. The reason I did that is because Svelte has a little file-path helper for the lib
folder. No matter how nested you are, you can use $lib
to automatically find that folder, instead of having to to use ../
, ../../
etc. to try and get into the correct folder. By convention, component file names start with a capital letter, so I made a Header.svelte
file and a Footer.svelte
file. I simply moved the header and footer code into their respective files and I was then able to reference them in my +layout.svelte
like this:
<script> let { children } = $props(); import Header from '$lib/components/Header.svelte'; import Footer from '$lib/components/Footer.svelte'; </script> <Header /> <!-- Main Content --> <main> {@render children()} </main> <!-- Footer --> <Footer />
When I added those, I decided everything need some styling. All my styles on the original site are in the style.css
file, so I moved all their styles into their corresponding each component or page, except for some global styles that I left in the style.css
file to apply across the site. I'll come back to the styling later, because there were a couple of gotchas.
After I added the Header
and Footer
components, I created another folder inside my components
folder called homepage
. Like with the header and footer, I took all the sections of my homepage (which is the in the +page.svelte
file in the root of the routes
folder, remember) and made them into components too. I then added their specific styles and imported them into my +page.svelte
file. Here's how all the code in that page looks:
<script> import Hero from '$lib/components/homepage/Hero.svelte'; import InitiativeSection from '$lib/components/homepage/InitiativeSection.svelte'; import ParticipationSection from '$lib/components/homepage/ParticipationSection.svelte'; import GetSocialSection from '$lib/components/homepage/GetSocialSection.svelte'; </script> <Hero /> <InitiativeSection /> <ParticipationSection /> <GetSocialSection />
Very neat!
So now I had my header, footer and homepage content all set up. I also have pages on each activity (walking, cycling, skateboarding, etc.) and a contact page. Let's start with the contact page since it was really just copying over HTML and styles. To make a new page in SvelteKit, you create a new folder in routes
folder with the name of the page. I made my folder called contact
. You then need to add another +page.svelte
file inside it, so it is contact > +page.svelte
. In that file, I added all my code and styles, so it worked just as expected.
Converting dynamic pages
The activities pages were a little trickier. On my original website, I have a dropdown link with the text "Getting started" and each of the activities listed below. However, the activities themselves do not come after a getting-started/
path. For example, the URL path for carpooling is /carpooling
, not /getting-started/carpooling
.
I knew that using Svelte that I wanted to dynamically render the pages' content since the structure was all the same. To do that, you need to create a new folder that will group them all. In my case, I called the folder getting-started
. Within that folder, you then create another folder that will contact your pages. However, this folder's name is surrounded by square brackets ( [ ]
) to indicate a dynamic page. In my case, it is called [id]
, so I will use each activity's ID to determine what content is show on the page.
To start, I created a +page.svelte
in the [id]
folder and added the content of one of the activities there. When I switched between the pages, I saw the same content (as expected for now) but was bothered that the URL was /getting-started/
plus the activity name. As it turns out, to make SvelteKit ignore that part of the URL path, I needed to put the getting-started
folder name in brackets, so that it was now (getting-started)
. My folder structure is now (getting-started) > [id] > +page.svelte
, so now my carpooling URL path is simply /carpooling
again. You can click here to read more (group) in the SvelteKit docs.
Now, I had my routes set up, but I needed to make it so that the each page displayed a different activity. With the original website, each page had its own HTML, but with Svelte, I wanted to have one page and then slot in the data where needed - e.g. activity title, activity description, activity tips, etc. To do that, I extracted the activity data and put it into a JSON object so it looked like this:
{ id: 'carpooling', title: 'Carpooling', subtitle: 'When you have to go by car, try and carpool', tips: [ { id: 'carpooling-tip-1', image: '/images/carpoolworld-logo.webp', tipTitle: 'Check carpooling websites', tipText: `<h4>CarpoolWorld</h4><p>The CarpoolWorld web app is a free service for finding someone to carpool with, or offering to carpool. It can be used for recurring trips (such as a daily commute) or one-off trips.</p> <a href="https://www.carpoolworld.com/carpool.html?form_language=EN&olat=&olon=&lat=&lon=&home_street=&hf=Cork" target="_blank" aria-label="Opens in new tab">Click here to go to Carpool World's listings for Cork</a>` }, { id: 'carpooling-tip-2', image: '/images/get-there-logo.webp', tipTitle: 'Combine carpooling with other methods', tipText: `<h4>GetThere.ie</h4><p>getthere.ie is an independent initiative to provide a single place to search for transport in Ireland. Services covered include:</p> <ul> <li>Rail (Irish Rail)</li> <li>Public Coach (Bus Éireann)</li> <li>Private/Regional Coach Operators (50+)</li> <li>Dublin connecting services (Dublin Bus, Luas & Dart) — see also hittheroad.ie for a map-based view of Dublin services.</li> <li>Lift Sharing</li> </ul> <a href="https://getthere.ie/" target="_blank" aria-label="Opens in new tab">Click here to go to the GetThere.ie's website</a>` } ] },
The tip images in that object are referenced using /images
, which is a folder I added to the static folder. In Svelte, when you need to reference something in your static folder, you can just add /
and then the resource path within the static file.
I realised that the activities would need to be access in two places - the Header
component and the activities pages. With that in mind, I needed to put it somewhere where both could get to it. In my lib
folder, I added a stores
folder in which I added an activities.js
file in which I added my activities array (export const activities = [...]
). From there, I needed to find a way to specify the activity so that I could output its data on my [id] > +page.svelte
. To do that, I needed to add as sibling file to that +page.svelte
called the +page.js
- that file can export a load function that then makes a data
prop available inside the +page.svelte
. I could then use that data
. Here's how my load
function looks:
/** @type {import('./$types').PageLoad} */ import { activities } from '$lib/stores/activities'; export function load({ params }) { return { id: params.id, // the url path (e.g. carpooling) activities }; }
In my +page.svelte
file, I can then retrieve the data using the Props rune with let { data } = $props();
. I could then use object de-structuring to get the values of activities and the id with const { id, activities } = data;
. Finally, I could use the .find array method to get the activity where the id
matched with the activity.id
using const activity = activities.find((activity) => activity.id === id);
. I also wanted to separate out the title and subtitle of the selected activity, so I used const { title, subtitle } = activity;
.
With my activity variables set up, I created two more components in the components
folder and passed my variables in:
<Title {title} {subtitle} /> <Tips {activity} />
Now when I went to an activity page, it would show the correct activity... each time the page refreshed... otherwise it would keep showing the same activity information even if the URL path was updating. After some research, I found out the solution was to use another Rune - $derived
. The issue of the component and page state being preserved occurs because the variables do not update. To make them update, I needed to update my syntax to make the variables reactive:
+page.svelte
<script> import Tips from '$lib/components/Tips.svelte'; import Title from '$lib/components/Title.svelte'; let { data } = $props(); const { id, activities } = $derived(data); const activity = $derived(activities.find((activity) => activity.id === id)); const { title, subtitle } = $derived(activity); </script> <Title {title} {subtitle} /> <Tips {activity} />
Tips.svelte
<script> import Tip from './Tip.svelte'; let { activity } = $props(); let { tips, id } = $derived(activity); </script> <!-- Pages tips section --> <section class="tips-section"> <ol class="tips-list"> {#each tips as tip, index} <li class="tip"> <Tip {tip} {id} {index} /> </li> {/each} </ol> </section>
Since I also output the activities in the Header for the dropdown menu, I also had to updated that component:
<script> import { page } from '$app/stores'; let { activities } = $props(); let urlPath = $derived($page.url.pathname.split('/')[1]); </script> <!-- Site Header --> <header> <div class="container"> <a href="/" class="logo-link" aria-label="Go to the Cork Car Cut homepage"> <picture> <source media="(min-width:479px)" srcset="/images/cork-car-cut-logo.webp" width="96" height="55" /> <img src="/images/cork-car-cut-logo-small.webp" alt="Cork Car Cut logo" width="64" height="37" /> </picture> </a> <!-- Site navigation items --> <nav aria-label="Site navigation"> <ul> <li class="nav__item"> <a href="/" class={urlPath === '' ? 'nav__link active' : 'nav__link'}>Home</a> </li> <!-- CSS only dropdown --> <li class="nav__item dropdown"> <button class={activities.find((activity) => urlPath === activity.id) ? 'nav__link active' : 'nav__link'} type="button" aria-haspopup="true" aria-controls="dropdown-list" > Getting Started <i class="fa-solid fa-chevron-down"></i> </button> <ul id="dropdown-list" class="dropdown-list" role="menu"> {#each activities as activity (activity.id)} <li class="dropdown__item"> <a href="/{activity.id}" class={activity.id == urlPath ? 'dropdown__link active' : 'dropdown__link'} >{activity.title}</a > </li> {/each} </ul> </li> <li class="nav__item"> <a href="contact" class={urlPath === 'contact' ? 'nav__link active' : 'nav__link'} >Contact</a > </li> </ul> </nav> </div> </header>
With those updates, the pages now updated as expected.
CSS
There were a couple of CSS gotchas throughout the migration.
- When loading my activity tips, I opted to render the HTML. My CSS in the
Tip.svelte
file was looking for.tip h4
, but because the HTML wasn't yet rendered, it looks like it was ignored. The trick to resolve that was to use:global
like this:
:global(.tip h4) { margin-top: 0.75em; font-size: 1.25rem; }
- Background images are ignored by Svelte in the
<style>
tags of components. In myHero.svelte
file, I had a hero image that was located in mystatic/images
folder. It turns out that Vite/Svelte (not sure which!) ignores those by default. Instead, I moved the file into mylib
folder and updated the logic in that file.
Conclusion
With the migration finished, I was able to update my Stackportfol.io page for Cork Car Cut with the updated deploy link, Github repo link and technologies used:
And that's it! Even though the site itself was simple, there were a challenges, which were sort of the things you don't know until you know. I encourage anyone who is interested in beginning with Svelte to try creating or migrating a simple HTML and CSS site to get the basics down first.