Creating a portfolio website can be a practical way to showcase resumes, projects, and blogs. As someone who leans more towards backend development, I found myself in the situation of wanting to establish an online presence without drowning myself into the complexities of React or dealing with the manual development with plain HTML, CSS, and JavaScript. It was in the search for a simpler alternatives that I found Svelte, and its meta-framework, SvelteKit.
The simplicity of Svelte's syntax, its templates, and its approach to frontend development, which relies on compilation rather than a virtual DOM, has won me over. And then there is SvelteKit, an extension of Svelte that simplifies the process of building websites, which made it a great choice for my portfolio website project. It offered a perfect balance between simplicity and powerful functionality that aligns well with my preferences.
However, the question of CSS styling remained. The vast ecosystem of Tailwind CSS, with its extensive class-based utility system and configurations, felt kind of overwhelming for my needs. On the other hand, creating styles entirely from scratch using pure CSS felt time-consuming and inefficient.
My search for a middle ground led me to Pico CSS, a minimalist CSS framework with a unique approach to styling. What makes Pico CSS different is its focus on elegant styling without the use of classes. Instead, it encourages the use of semantic HTML tags to implement predefined styles rather than abusing <div>
tags with CSS classes, making it an interesting choice for those who prefer to keep their HTML clean and meaningful.
In this blog post, I will try to explain the technical aspects of how I utilized SvelteKit and Pico CSS to create a portfolio website that effectively presents my resume, projects, and blogs.
Initiate SvelteKit Project
Getting started with a SvelteKit project is a straight-forward process. The easiest way is to run npm create
command:
npm create svelte@latest my-app
It will scaffold a new project in the my-app
directory, asking questions to set up some basic tooling such as TypeScript. However, I'm using JSDoc for this project, because it is good enough for my needs.
SvelteKit follows conventions that encourage efficient code organization. There are two basic concepts:
- Each page is a Svelte component.
- Create more pages by adding files to the
src/routes
directory of the project.
I used the index page for my resume. Then, I created 2 specific directories, blogs
and projects
, along with their mandatory +page.svelte
and +page.server.js
files. These files served as the components that rendered the content for my /blogs
and /projects
pages respectively.
src/routes/
├── blogs
│ ├── +page.server.js
│ └── +page.svelte
├── +error.svelte
├── +layout.svelte
├── +page.svelte
└── projects
├── +page.server.js
└── +page.svelte
3 directories, 7 files
Additionally, I added a +error.svelte
file for rendering custom error messages when needed.
<script>
import { page } from '$app/stores';
/** @type {Record<number, string>} map of status codes to emojis */
const emojis = {
400: '❌', // bad request
401: '🔒', // unauthorized
403: '🚫', // forbidden
404: '🔍', // not found
500: '🛠', // internal server error
502: '🔌', // bad gateway
503: '🔧', // service unavailable
504: '⏳' // gateway timeout
};
</script>
<section id="error" class="container">
<h1><strong>{$page.status}</strong></h1>
<h2>{$page.error?.message}</h2>
<span id="emoji">
{emojis[$page.status] ?? emojis[500]}
</span>
</section>
<style>
h1,
h2 {
margin-bottom: 1rem;
}
#error {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
margin: 0rem;
}
#emoji {
font-size: 10rem;
}
</style>
For static data, I put them as a JSON object inside src/lib/store.js
file.
/**
* Experience data for the resume.
*
* @type {Array<{
* job: string,
* url: string,
* company: string,
* start: string,
* end: string,
* description: Array<string>
* }>}
*/
export const experiences = [
// ...
];
/**
* Volunteering data for the resume.
*
* @type {Array<{
* job: string,
* url: string,
* company: string,
* start: string,
* end: string,
* description: Array<string>
* }>}
*/
export const volunteering = [
// ...
];
/**
* Social data for the resume.
*
* @type {Array<{
* href: string,
* rel: string,
* fa_class: string,
* }>}
*/
export const socials = [
// ...
];
/**
* Education data for the resume.
*
* @type {Array<{
* school: string,
* url: string,
* major: string,
* start: string,
* end: string,
* description: string
* }>}
*/
export const educations = [
// ...
];
/** Font Awesome icons name for the skills data. */
export const skills = [
// ...
];
/** Workflows data for the resume. */
export const workflows = [
// ...
];
/**
* Awards data for the resume.
*
* @type {Array<{
* place: number,
* suffix: string,
* host: string,
* competition: string,
* translation: string
* }>}
*/
export const awards = [
// ...
];
/**
* Certification data for the resume.
*
* @type {Array<{
* title: string,
* credential_id: string,
* credential_url: string
* }>}
*/
export const certifications = [
// ...
];
I also put all the necessary component files inside the src/lib/components
directory.
src/lib/components/
├── BlogCard.svelte
├── EduCard.svelte
├── Footer.svelte
├── JobCard.svelte
├── Navbar.svelte
├── ProjectCard.svelte
├── SkillsCard.svelte
└── SocialIcon.svelte
1 directory, 8 files
And for JavaScript files, I put them in the src/lib/scripts
directory.
src/lib/scripts/
├── minimal-theme-switcher.js
└── redis.js
1 directory, 2 files
Styling with Pico CSS
To get started with Pico CSS, all I had to do was install it via npm by using the straight-forward command:
npm install -D @picocss/pico
After that, I included it into my project by importing it in the +layout.svelte
file, specifically inside the <script>
tag.
<script>
// ...
import '@picocss/pico';
// ...
</script>
And that was basically it. I was able to use Pico CSS's elegant styles for all native HTML elements without the need for classes, enhancing the overall aesthetics of my website.
I also borrowed some code from Pico CSS's example code for a theme switcher by manipulating the data-theme
attribute for the <html>
tag using JavaScript. I placed the code inside src/lib/scripts/minimal-theme-switcher.js
file.
/*!
* Minimal theme switcher
*
* Pico.css - https://picocss.com
* Copyright 2019-2023 - Licensed under MIT
*/
/**
* Minimal theme switcher
*
* @namespace
* @typedef {Object} ThemeSwitcher
* @property {string} _scheme - The current color scheme ("auto", "light", or "dark").
* @property {string} menuTarget - The selector for the menu element that contains theme switchers.
* @property {string} buttonsTarget - The selector for theme switcher buttons.
* @property {string} buttonAttribute - The attribute name used for theme switcher buttons.
* @property {string} rootAttribute - The attribute name used for the root HTML element to store the selected theme.
* @property {string} localStorageKey - The key used to store the preferred color scheme in local storage.
*/
export const ThemeSwitcher = {
// Config
_scheme: 'auto',
menuTarget: "details[role='list']",
buttonsTarget: 'a[data-theme-switcher]',
buttonAttribute: 'data-theme-switcher',
rootAttribute: 'data-theme',
localStorageKey: 'picoPreferredColorScheme',
/**
* Initialize the theme switcher.
*
* @function
* @memberof ThemeSwitcher
*/
init() {
this.scheme = this.schemeFromLocalStorage || this.preferredColorScheme;
this.initSwitchers();
},
/**
* Get the color scheme from local storage or use the preferred color scheme.
*
* @function
* @memberof ThemeSwitcher
* @returns {string|null} The color scheme ("light", "dark", or null).
*/
get schemeFromLocalStorage() {
if (typeof window.localStorage !== 'undefined') {
if (window.localStorage.getItem(this.localStorageKey) !== null) {
return window.localStorage.getItem(this.localStorageKey);
}
}
return this._scheme;
},
/**
* Get the preferred color scheme based on user preferences.
*
* @function
* @memberof ThemeSwitcher
* @returns {string} The preferred color scheme ("light" or "dark").
*/
get preferredColorScheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
},
/**
* Initialize the theme switcher buttons and their click events.
*
* @function
* @memberof ThemeSwitcher
*/
initSwitchers() {
const buttons = document.querySelectorAll(this.buttonsTarget);
buttons.forEach((button) => {
button.addEventListener(
'click',
(event) => {
event.preventDefault();
// Set scheme
this.scheme = button.getAttribute(this.buttonAttribute) || 'auto';
// Close dropdown
document.querySelector(this.menuTarget)?.removeAttribute('open');
},
false
);
});
},
/**
* Set the selected color scheme and update the UI.
*
* @function
* @memberof ThemeSwitcher
* @param {string} scheme - The color scheme to set ("auto", "light", or "dark").
*/
set scheme(scheme) {
if (scheme == 'auto') {
this.preferredColorScheme == 'dark' ? (this._scheme = 'dark') : (this._scheme = 'light');
} else if (scheme == 'dark' || scheme == 'light') {
this._scheme = scheme;
}
this.applyScheme();
this.schemeToLocalStorage();
},
/**
* Get the current color scheme.
*
* @function
* @memberof ThemeSwitcher
* @returns {string} The current color scheme ("auto", "light", or "dark").
*/
get scheme() {
return this._scheme;
},
/**
* Apply the selected color scheme to the HTML root element.
*
* @function
* @memberof ThemeSwitcher
*/
applyScheme() {
document.querySelector('html')?.setAttribute(this.rootAttribute, this.scheme);
},
/**
* Store the selected color scheme in local storage.
*
* @function
* @memberof ThemeSwitcher
*/
schemeToLocalStorage() {
if (typeof window.localStorage !== 'undefined') {
window.localStorage.setItem(this.localStorageKey, this.scheme);
}
}
};
Then, I used it in the Navbar.svelte
component within the <script>
section, utilizing Svelte's onMount
feature.
<script>
import { ThemeSwitcher } from '$lib/scripts/minimal-theme-switcher';
import { onMount } from 'svelte';
onMount(() => {
ThemeSwitcher.init();
});
</script>
Font Awesome Icons
Utilizing Font Awesome icons in my project was easy. By integrating the Font Awesome Kit, I have access to a vast icon library that covers various categories, from social media icons to common UI elements. Even though using the kit may not be the fastest way to render icons on the web, the flexibility of the kit allows me to select and combine icons that blend seamlessly with my website's design, adding aesthetic value and a better user experience.
Fetch Blogs Data from Dev.to API
For populating my /blogs
page with my published blog articles, I utilized Dev.to's API as provided in their documentation. I included a username
query parameter with my username as the value:
https://dev.to/api/articles?username=bagashiz
This query allowed me to get my published blog articles from the API and integrate them into my /blogs
page on my portfolio website.
To enhance user experience and website performance, I implemented a strategy where I returned the data as a streamed promise.
import { env } from '$env/dynamic/private';
/** @type {import('./$types').PageServerLoad} */
export async function load() {
const url = env.BLOG_URL;
return {
streamed: {
/**
* @type {Promise<Blog[]>} blogs - Array of dev.to blogs
*/
blogs: new Promise((resolve) => {
fetch(url)
.then((res) => res.json())
.then((blogs) => {
resolve(blogs);
});
}
})
}
};
}
This approach allowed me to render the page with a placeholder before the actual blog data was fetched.
<script>
import BlogCard from '$lib/components/BlogCard.svelte';
/** @type {import('./$types').PageData} */
export let data;
</script>
<section>
<!-- ... -->
{#await data.streamed.blogs}
<article aria-busy="true" id="skeleton" class="outline">
<strong>Fetching data...</strong>
</article>
{:then blogs}
{#each blogs as blog}
<BlogCard {blog} />
{/each}
{:catch}
<article id="skeleton" class="outline">
<strong>Uh oh! Failed to fetch data.</strong>
</article>
{/await}
</section>
<style>
/* ... */
</style>
This approach not only ensured a smooth and responsive user experience but also provided an nice way to handle the loading of external data.
Fetch Projects Data from GitHub GraphQL API
To populate my /projects
page with information about my pinned GitHub repositories, I utilized GitHub GraphQL API. This API allows for more specific and efficient data retrieval compared to traditional REST APIs.
However, fetching data from the GitHub GraphQL API involved a slightly different process. Instead of using simple GET
requests, I needed to use the POST
method. To specify the data I wanted to retrieve, I included a GraphQL query in the request body:
{
user(login: "bagashiz") {
pinnedItems(first: 6, types: REPOSITORY) {
nodes {
... on Repository {
name
description
url
primaryLanguage {
name
color
}
stargazers {
totalCount
}
forks {
totalCount
}
}
}
}
}
}
This query allowed me to request my pinned GitHub repositories, such as repository names, descriptions, stars and forks count, primary languages, and links.
Additionally, I also needed to add a bearer token to the Authorization header of the request. This token was generated using a GitHub personal access token with the necessary permissions, including access to public repositories and the ability to read all user profile data.
import { env } from '$env/dynamic/private';
/**
* @type {string} url - Github GraphQL API endpoint
*/
const url = 'https://api.github.com/graphql';
/**
* @type {string} query - GraphQL query to fetch pinned github repositories
*/
const query = `...`;
/** @type {import('./$types').PageServerLoad} */
export async function load() {
const key = 'projects';
const token = env.GITHUB_ACCESS_TOKEN;
const reqInfo = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `bearer ${token}`
},
body: JSON.stringify({ query })
};
return {
streamed: {
/**
* @type {Promise<Project[]>} projects - Array of GitHub projects
*/
projects: new Promise((resolve) => {
fetch(url, reqInfo)
.then((res) => res.json())
.then(({ data }) => {
const projects = data.user.pinnedItems.nodes;
resolve(projects);
});
}
})
}
};
}
Caching API Data with Redis
To make data retrieval efficient and minimize the need to request data from the API on every page load, I implemented a caching strategy using the ioredis package, which is a popular redis client for Node.js for interacting with a Redis database.
import { env } from '$env/dynamic/private';
import { Redis } from 'ioredis';
/**
* @type {Redis} redis - Redis client instance
*/
const redis = new Redis({
host: env.REDIS_HOST,
port: parseInt(env.REDIS_PORT || '6379')
});
redis.on('error', (error) => {
console.error(`Error initializing redis client: ${error}`);
});
export default redis;
For caching strategy, I implemented the Cache-Aside pattern. This process involves fetching data from an external API, storing it in the Redis cache, and using the cached data for the next request. I also configured the cache to expire after an hour, making sure to keep it efficient and up-to-date.
const key = 'blogs';
// ...
return {
streamed: {
/**
* @type {Promise<Blog[]>} blogs - Array of dev.to blogs
*/
blogs: new Promise((resolve) => {
redis.get(key).then((data) => {
if (data) {
const blogs = JSON.parse(data);
resolve(blogs);
} else {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] Cache miss, getting data from dev.to API`);
fetch(url)
.then((res) => res.json())
.then((blogs) => {
redis.setex(key, 3600, JSON.stringify(blogs));
resolve(blogs);
});
}
});
})
}
};
// ...
const key = 'projects';
// ...
return {
streamed: {
/**
* @type {Promise<Project[]>} projects - Array of GitHub projects
*/
projects: new Promise((resolve) => {
redis.get(key).then((data) => {
if (data) {
const projects = JSON.parse(data);
resolve(projects);
} else {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] Cache miss, getting data from GitHub API`);
fetch(url, reqInfo)
.then((res) => res.json())
.then(({ data }) => {
const projects = data.user.pinnedItems.nodes;
redis.setex(key, 3600, JSON.stringify(projects));
resolve(projects);
});
}
});
})
}
};
// ...
Trying Out New View Transitions API
The view transitions API streamlines the process of animating between two page states, which is especially useful for page transitions. This excellent blog post by Geoff Rich offered valuable insights and guidelines on implementing this new feature to my project.
I set up the new View Transitions API in my SvelteKit project using the onNavigate()
function inside the +layout.svelte
file. This function enabled the transitions between different page states seamlessly.
<script>
// ...
// view transition between routes
onNavigate((navigation) => {
// @ts-ignore
if (!document.startViewTransition) return;
return new Promise((resolve) => {
// @ts-ignore
document.startViewTransition(async () => {
resolve();
await navigation.complete;
});
});
});
// ...
</script>
I also implemented custom transitions using CSS. These custom transitions were defined inside the app.css
file.
@keyframes fade-in {
from {
opacity: 0;
}
}
@keyframes fade-out {
to {
opacity: 0;
}
}
@keyframes slide-from-right {
from {
transform: translateX(30px);
}
}
@keyframes slide-to-left {
to {
transform: translateX(-30px);
}
}
/**
* slide animation if prefers-reduced-motion is not set,
* else default cross-fade animation
*/
@media (prefers-reduced-motion: no-preference) {
:root::view-transition-old(root) {
animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}
:root::view-transition-new(root) {
animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}
}
Deployment
With SvelteKit, deploying my portfolio website can't get any simpler. The first step is to switch from the default SvelteKit adapter to using the Node.js adapter.
Then I created a Dockerfile that specified all the necessary dependencies and configurations. I used a multi-stage build approach to reduce the size of the final image.
FROM node:18.18.0-alpine3.18 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:18.18.0-alpine3.18 AS prod
USER node:node
WORKDIR /app
COPY --from=build --chown=node:node /app/build ./build
COPY --from=build --chown=node:node /app/package.json ./
RUN npm i --omit=dev
CMD ["node", "build"]
After that, I set up a GitHub workflow to build and publish the Docker image to the GitHub Container Registry automatically.
name: Create and publish a Docker image
on:
push:
branches: ["main"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4.1.0
- name: Log in to the Container registry
uses: docker/login-action@v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v5.0.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
Finally, I pulled that image and deployed it to my VPS. You can see it live now at https://bagashiz.me!
Source Code
If you're interested in exploring more about this project, you can check out the source code in my GitHub repository
bagashiz / portfolio
Class-less personal portfolio & resume website built using Go, Templ, HTMX, and styled with Pico CSS.
Portfolio
Description
My personal portfolio website for showcasing my resume, projects, and blog posts. The site is built using Go, Templ, HTMX, and Pico CSS. The site also uses KeyDB as a drop-in replacement for Redis.
The featured GitHub projects are dynamically retrieved through the power of the GitHub GraphQL API. The blog posts are seamlessly pulled in using the Dev.to API. Additionally, KeyDB is used to cache the GitHub and Dev.to API responses for 1 hour to reduce the number of API calls. Icons are provided by Font Awesome through their kit from the CDN. I've also implemented the new View Transition API feature to enhance the user experience.
There is an old stack version of the site in the sveltekit
branch. The old stack version of the site was built using SveteKit.
Demo
Check out the live demo at bagashiz.xyz!
Dependencies
- Go version 1.22 or…
Top comments (0)