Build a Personal Timeline with GraphCMS and SvelteKit

Build and deploy your own personal timeline inspired by Polywork, with GraphCMS and SvelteKit

Scott Spence
Building a Personal Timeline with GraphCMS and SvelteKit

In this guide, I'll be making a personal timeline using GraphCMS and SvelteKit, heavily inspired by Polywork’s approach to building personal profiles. If you’re wondering what Polywork is, it's a new platform where you can create a whole timeline of all your milestones and achievements and not just the roles or positions you have worked in before.

Here's what the end result will look like:

Polywork Clone with GraphCMS.png

This option will give you that timeline you can add to your own site or have it as a stand-alone webpage.

This guide will cover getting your backend set up in GraphCMS and creating your own content model of your timeline. I'll then go into displaying that content model on a web page.

If you've not used GraphCMS before, you can take a look at the video playlists for how to Get Started with GraphCMS and for further details, there's also the How to GraphCMS playlist to check out.

Create the content modelAnchor

Taking a look at a user's Polywork profile (timeline) I'll break that down into the relevant content types.

So a high-level overview of the content types I'm going to need will be:

  • User: name, current position, location, bio, and pronoun.
  • User Badges: what your interests are, JavaScript, Rock Climbing, etc.
  • Activity Detail: this is the detail of what you did, like 'started a side project'.
  • Tags: These are the tags you've applied to your activity, like 'Published a YouTube video'.

I'll now break down the content model as I'm going to create it in GraphCMS. All of the GraphCMS features I'll be using can be done with the community plan in this example.

  • Model Name: Timeline User

    • Field types
      • Name > Single line text
      • Username > Single line text > Validations > Set fields as unique
      • Job title > Single line text
      • Company > Single line text
      • Pronoun > Single line text
      • Location > Single line text
      • Avatar > Asset picker
      • Banner > Asset picker
      • Bio > Multi line text

Timeline User Model.png

  • Model Name: User Badge

    • Field types
      • Name > Single line text
      • Icon > Asset

User Badge Model.png

  • Model Name: Activity Detail

    • Field types
      • Date > Date
      • Description > Rich text

Activity Detail Model.png

  • Model Name: Activity Tag

    • Field types
      • Name > Single line text
      • Badge Colour > Color

Activity Tag Model.png

Finally, for the content model, I will add some additional fields for the assets to include an alt text and a caption, Alt text as a 'Single line text' and the Caption as a 'Multi line text'.

Asset Model.png

Now that I have the models created, I can add in the relations for them with a Reference field:

  • Timeline User

    • User Badge > Model to reference > User badge > Allow multiple UserBadges per TimelineUser
    • Activity Details > Model to reference > Activity Detail > Allow multiple ActivityDetails per TimelineUser

User Badge Reference.png

If there were more than one user in the content model, I'd also check to Allow multiple Users per UserBadge and the same for the ActivityDetail. As it's only for me I'll keep it one to many for both.

Now there's a reverse field for Timeline User in the User Badge model and also for the Activity Detail.

  • User Badge

    • TimelineUser > Reference

User Badge with Reference.png

  • Activity Detail

    • Timeline User > Reference
    • Activity Tag > Model to reference > Activity Tag > Allow multiple ActivityDetails per ActivityTag + Allow multiple ActivityTags per ActivityDetail

Activity Detail Relation to Activity Tag.png

  • Activity Tag
    • Activity Details > Reference

If you're following along feel free to add your own validations to the fields, as it's only going to be small amounts of information I'm adding I'm going to leave them with the default validation.

Now that I have the content model created, I'll need to enable public access for the front end to use it.

In Settings > API access > Public Content API I'll enable the defaults by clicking the 'Yes, initialize defaults' button.

GraphCMS content API permissions default.png

I'll copy some of the content from my Polywork profile and add it to my content model now.

The front-endAnchor

For the front end, I'll be using SvelteKit, ultimately this can be any framework you are comfortable with. For the ease of scaffolding out the project, I'll be using SvelteKit along with DaisyUI which utilizes Tailwind and Tailwind prose.

As this is a single-page project and the main part of the project is on a single file, this can be done in any technology of your choosing.

I'll initialize the project with the following:

npm init svelte@next timeline

In my case I'm going to be choosing the following CLI options:

  • Skeleton project
  • No to TypeScript
  • No to ESLint
  • Yes to Prettier

Now if I pop open my text editor (VSCode), this is the basic project structure I have:

src/
│ └─ routes/
│ │ └─ index.svelte
│ └─ app.html
├─ static/
├─ .gitignore
├─ .npmrc
├─ .prettierignore
├─ .prettierrc
├─ jsconfig.json
├─ package.json
├─ README.md
└─ svelte.config.js

Time to install the dependencies, Tailwind and DaisyUI, along with Tailwind typography for great typography defaults it offers. Those along with GraphQL request, GraphQL.js (graphql) and Date FNS:

# add additional dependencies
npx svelte-add tailwindcss --jit
# 👆 this will configure Tailwind with Just In Time
npm i -D daisyui @tailwindcss/typography graphql-request graphql date-fns
npm i # shorthand for npm install

The Tailwind JIT compiler is optional, if you don't want to use it then you can remove the --jit from the npx command.

If you're following along, you'll notice svelte-add for Tailwind added some extra files to the project and changed others:

src/
│ └─ routes/
│ │ └─ __layout.svelte
│ └─ app.postcss
├─ postcss.config.js
├─ svelte.config.js
├─ tailwind.config.cjs
...rest mainly unchanged

Note the __layout.svelte was added and some additional config files for post CSS and Tailwind.

Popping open the __layout.svelte file I can see that the global CSS file has been added for the whole project:

<script context="module">
import '../app.postcss'
</script>
<main>
<slot />
</main>

I'm going to leave this unchanged and focus on the routes/index.svelte file for the majority of the work.

I'll need to add the typography plugin and DaisyUI to the tailwind.config.js file:

const config = {
mode: 'jit',
purge: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {},
},
plugins: [require('@tailwindcss/typography'), require('daisyui')],
}
module.exports = config

There's also the default theme I'll need to set for DaisyUI in the srcapp.html file:

<!DOCTYPE html>
<html lang="en" data-theme="corporate">
<head>
<!-- rest of the file unchanged -->
</head>
</html>

Note that DaisyUI is after the typography plugin on the plugins array.

Let's get to work!Anchor

As I mentioned earlier this will be a single file project, if you're following along and want to break it into more manageable parts, I'll leave that to you.

Import the data for use on the pageAnchor

Now, I need to get the data from the GraphCMS endpoint, as it's a public endpoint I can put it straight into the code, but I'll add this to an environment file so that it can be used from one place. I'll create a .env file and add in the endpoint there:

touch .env

In the newly created .env file, I'll create an environment variable and assign the project endpoint to it:

VITE_CONTENT_API=https://api-eu-central-1.graphcms.com/v2/projectid/master

Not the VITE_ prefix is needed for Svelte to access the variable, Vite is the SvelteKit build tool and the VITE_ prefix is needed so the variable can be accessed on the client (browser).

Because I don't want to commit the .env file to GitHub I'll add .env to the .gitignore file, here's what my git ignore looks like:

.DS_Store
node_modules
/.svelte-kit
/package
.env

Now that I have the endpoint available to me via Vite, I can go about getting the data to use in the project.

I'll add a <script context="module"> to the top of my index.svelte file, this will fetch the data before the page loads.

In that block, I'm going to define a new GraphQLClient as graphcms then I'll bring in the API endpoint to use in the client.

I'll need to define a query to retrieve all the timeline information to use. I'll come onto that next for now. I'll define an empty gql tag to be passed to the client. The results from that query are then returned as props for the client to use.

<script context="module">
import { gql, GraphQLClient } from 'graphql-request'
export async function load() {
const graphcms = new GraphQLClient(
import.meta.env.VITE_CONTENT_API,
{
headers: {},
}
)
const query = gql`
`
const { timelineUser } = await graphcms.request(query)
return {
props: {
timelineUser,
},
}
}
</script>

Now, I need to define the query needed to pull all the user and timeline information.

In the GraphCMS API playground I'll define all the fields I need:

{
timelineUser(where: { username: "spences10" }) {
id
name
username
jobTitle
company
pronoun
location
bio
avatar {
url
altText
}
banner {
url
altText
}
userBadges {
name
}
activityDetails(orderBy: createdAt_DESC) {
date
description {
html
}
activityTags {
id
name
badgeColour {
hex
}
}
}
}
}

That query can go into the empty gql tag I had in the previous code example. Note that I have hardcoded the timeline user with my username timelineUser(where: { username: "spences10" }), if you have more than one user then this would be a good time to look into SvelteKit routing. I won't be covering that in this post but you can check out the documentation if you want to take it to the next level with this project.

Sweet! So now that I have the data available to me to use in the client, I can now focus on displaying that data.

Use the data on the pageAnchor

I'll add a <script> directly under the closing <script context="module"> tag. This is where I can access the props returned by the <script context="module"> block.

I'll define export let timelineUser prop then destructure all the variables I'll need from it, and notice all the variables match up to the GraphQL query.

<script>
export let timelineUser
let { name, username, jobTitle, company, pronoun, location, bio, banner, avatar, userBadges, activityDetails } = timelineUser
</script>

Now I'll go about first adding in the banner, this will be at the top of the page taking up the full width of the page:

<img alt="{banner.altText}" src="{banner.url}" class="h-96 w-full" />

Now I'll add in some containers for the basics card (username, job title etc...) and the activity list, then create a component for those to be used in the index.svelte file:

<!-- Containers -->
<article class="mx-auto -mt-72 max-w-7xl px-4 relative sm:px-6 lg:px-8">
<div class="mx-auto max-w-3xl">
<!-- Basics Card -->
<div class="divider opacity-10 mb-10" />
<!-- Activities List -->
</div>
</article>

Basics cardAnchor

I'll create a new folder and files for the basics card and the activities components now in the terminal:

mkdir src/lib
touch src/lib/basics.svelte
touch src/lib/activities.svelte

In the basics.svelte file I'm going to need to import some props, I'll start with the avatar and the name. For this, I'll add the props inside some <script> tags and scaffold out the markup for the avatar and the name:

<script>
export let avatar
export let name
</script>
<div class="bg-white border my-16 card shadow">
<figure class="px-10 pt-10">
<div class="avatar">
<div class="rounded-full h-32 mb-8 w-32">
<img alt="{avatar.altText}" src="{avatar.url}" />
</div>
</div>
<h2 class="font-bold text-left leading-relaxed text-2xl">{name}</h2>
</figure>
</div>

Then, I can add in the following for username, jobTitle, company, pronoun and location to an unordered list:

<script>
export let avatar
export let name
export let username
export let jobTitle
export let company
export let pronoun
export let location
</script>
<div class="bg-white border my-16 card shadow">
<figure class="px-10 pt-10">
<div class="avatar">
<div class="rounded-full h-32 mb-8 w-32">
<img alt="{avatar.altText}" src="{avatar.url}" />
</div>
</div>
<h2 class="font-bold text-left leading-relaxed text-2xl">{name}</h2>
<ul class="flex space-x-2 text-left leading-relaxed opacity-75">
<li>@{username}</li>
<span class="opacity-75">&bull;</span>
<li>{jobTitle},</li>
<li>{company}</li>
<span class="opacity-75">&bull;</span>
<li>{pronoun}</li>
<span class="opacity-75">&bull;</span>
<li>{location}</li>
</ul>
</figure>
</div>

Next up for the basics card is adding in any user badges, I'll use the Svelte 'if' directive {#if userBadges} to check if there are any badges to render then looping over each badge in the userBadges props if there is with the Svelte 'each' directive {#each userBadges as { name }}:

<script>
export let avatar
export let name
export let username
export let jobTitle
export let company
export let pronoun
export let location
export let userBadges
</script>
<div class="bg-white border my-16 card shadow">
<figure class="px-10 pt-10">
<div class="avatar">
<div class="rounded-full h-32 mb-8 w-32">
<img alt="{avatar.altText}" src="{avatar.url}" />
</div>
</div>
<h2 class="font-bold text-left leading-relaxed text-2xl">{name}</h2>
<ul class="flex space-x-2 text-left leading-relaxed opacity-75">
<li>@{username}</li>
<span class="opacity-75">&bull;</span>
<li>{jobTitle},</li>
<li>{company}</li>
<span class="opacity-75">&bull;</span>
<li>{pronoun}</li>
<span class="opacity-75">&bull;</span>
<li>{location}</li>
</ul>
<div>
{#if userBadges}
<div class="flex flex-wrap mt-5 break-words relative">
{#each userBadges as { name }}
<div class="border rounded-full font-medium mr-2 mb-2 py-2 px-4">{name}</div>
{/each}
</div>
{/if}
</div>
</figure>
</div>

Last up is adding in the Multi line text for the bio, note I've added in some JavaScript to take care of any line breaks with {@html bio.split('\n').join('<br />')}, the @html is to render HTML directly if this is left out it will show the HTML tags in the markup:

<script>
export let avatar
export let name
export let username
export let jobTitle
export let company
export let pronoun
export let location
export let userBadges
export let bio
</script>
<div class="bg-white border my-16 card shadow">
<figure class="px-10 pt-10">
<div class="avatar">
<div class="rounded-full h-32 mb-8 w-32">
<img alt="{avatar.altText}" src="{avatar.url}" />
</div>
</div>
<h2 class="font-bold text-left leading-relaxed text-2xl">{name}</h2>
<ul class="flex space-x-2 text-left leading-relaxed opacity-75">
<li>@{username}</li>
<span class="opacity-75">&bull;</span>
<li>{jobTitle},</li>
<li>{company}</li>
<span class="opacity-75">&bull;</span>
<li>{pronoun}</li>
<span class="opacity-75">&bull;</span>
<li>{location}</li>
</ul>
<div>
{#if userBadges}
<div class="flex flex-wrap mt-5 break-words relative">
{#each userBadges as { name }}
<div class="border rounded-full font-medium mr-2 mb-2 py-2 px-4">{name}</div>
{/each}
</div>
{/if}
</div>
<div class="my-5 opacity-75">
<p>{@html bio.split('\n').join('<br />')}</p>
</div>
</figure>
</div>

Now, I can go over to the index.svelte file and import the basics card at the top of the file:

<script context="module">
import Basics from '$lib/basics.svelte'

Then to use the basics card and pass it all the props it needs:

<!-- Containers -->
<article
class="mx-auto -mt-72 max-w-7xl px-4 relative sm:px-6 lg:px-8"
>
<div class="mx-auto max-w-3xl">
<!-- Basics Card -->
<Basics
{avatar}
{name}
{bio}
{username}
{jobTitle}
{company}
{pronoun}
{location}
{userBadges}
/>
<div class="divider opacity-10 mb-10" />
<!-- Activities List -->
</div>
</article>

In Svelte, if the name of the prop being passed is the same as what's being expected by the component then there's no need to define the name of the prop, so avatar={avatar} becomes {avatar} neat eh?

Here's what I have so far:

Basics Card with Props.png

Activity Details cardAnchor

Now to go through the same process for the activities card, I've already got the file so in src/lib/activities.svelte I'll start scaffolding out what's needed there, this one is a bit simpler as it's only taking the activityDetails prop:

<script>
import { format } from 'date-fns'
export let activityDetails
</script>
<section>
{#if activityDetails}
{#each activityDetails as activity}
<section class="border mb-4 card shadow">
<div class="card-body">
{#if activity.activityTags}
{#each activity.activityTags as { name, badgeColour }}
<div class="flex flex-wrap break-words relative">
<div
class="border font-semibold mr-2 text-sm mb-2 w-auto py-2 px-4"
>
<p style="color:{badgeColour.hex}">{name}</p>
</div>
</div>
{/each}
{/if}
<p class="font-semibold mb-5 opacity-75">
{format(new Date(activity.date), 'do MMM yyy')}
</p>
<div class="prose">
{@html activity.description.html}
</div>
</div>
</section>
{/each}
{/if}
</section>

Again, there's a lot of use of the Svelte directives for 'if' and 'each' here along with the @html tag. I'll break it down a bit into what's going on here. The first 'if' is to conditionally render any activities if there are any and the next is to check if there are any activity tags applied to the activity.

If there are tags, they are being looped over to add to the activity. I'm using date-fns to format the date as I like then finally rendering out the activity detail!

Now, I can import this component into my index.svelte file at the top of the file like with the basics card:

<script context="module">
import Activities from '$lib/activities.svelte'
import Basics from '$lib/basics.svelte'

And that's it! 😅

If you want to check out the code, I've made it available on GitHub.

DeployAnchor

I'll be deploying this project to Vercel. There are other providers available like Render and Netlify if you prefer, but for the ease of setting up, I'll be using Vercel.

I'll need to install a SvelteKit adapter for Vercel, I'll install it with the following command:

npm i -D @sveltejs/adapter-vercel@next

Note the @next as part of the install.

Next up, I'll need to add the adapter to my svelte.config.js file, here's what mine looks like:

import adapter from '@sveltejs/adapter-vercel'
import preprocess from 'svelte-preprocess'
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// hydrate the <div id="svelte"> element in src/app.html
target: '#svelte',
adapter: adapter(),
},
preprocess: [
preprocess({
postcss: true,
}),
],
}
export default config
// Workaround until SvelteKit uses Vite 2.3.8 (and it's confirmed to fix the Tailwind JIT problem)
const mode = process.env.NODE_ENV
const dev = mode === 'development'
process.env.TAILWIND_MODE = dev ? 'watch' : 'build'

From here, I can use the Vercel CLI and use vc from the command line to deploy it. I have also the added option to link the Vercel project to a GitHub repo.

If you're using SvelteKit and want to use a different hosting provider there are several adapters available via the SvelteKit documentation.

That's it, thanks for reading, if you want to take a look at the code for this you can find it over on my GitHub and the working example can be found here.

It's Easy To Get Started

GraphCMS plans are flexibly suited to accommodate your growth. Get started for free, or request a demo to discuss larger projects with more complex needs