Custom Research Website
A maintainable, reusable and extensible website template to tell your stories.
npx degit Vermont-Complex-Systems/vcsi-starter/templates/baked example npx degit Vermont-Complex-Systems/vcsi-starter/templates/fresh example Why Svelte and Sveltekit?
Use Any JS Library Directly
Svelte compiles to vanilla JavaScript, so you can use D3, Three.js, Observable Plot, or any library without wrappers. No React-specific ports needed.
Perfect for Data Visualization
Fine-grained reactivity means smooth transitions and efficient updates. Used by The Pudding and data journalism teams for interactive charts, maps, and scrollytelling, e.g. how to build a Beeswarm Chart with Svelte and D3.
Readable Code for Collaborators
Svelte components look like HTML with superpowers. Graduate students and collaborators can contribute without deep framework knowledge.
As Simple as Possible, but No Simpler
The Svelte team believes in removing unnecessary complexity without sacrificing power. Read more or watch Rich Harris talking about Svelte 5's North Star.
Minimal Boilerplate
No useState, useEffect, or dependency arrays. Reactivity is built into the language. Spend time on your research, not fighting the framework.
Progressive Enhancement
Forms and links work without JavaScript, then get enhanced when it's available. Your app stays resilient while still feeling modern when JS loads.
Learn more at svelte.dev or follow their tutorial
Project Structure
.
└── src
├── data # CSV data for routes
├── lib
│ ├── components/ # Reusable UI components
│ │ ├── About.svelte
│ │ ├── StoryGrid.svelte
│ │ ├── Home.svelte
│ │ └── ...
│ ├── server
│ ├── stories # Your scrollytelling content
│ │ └── story-1
│ │ ├── components/Index.svelte
│ │ └── data/copy.json
│ └── story.remote.ts
├── routes # Pages & layouts
│ ├── (app)
│ │ ├── +layout.svelte # Non-story default layouts
│ │ ├── +page.svelte # Home page
│ │ ├── about
│ │ │ ├── [name]
│ │ │ │ └── +page.svelte
│ │ │ └── +page.svelte
│ │ └── getting-started
│ │ └── +page.svelte
│ ├── [story] # Dynamic story routes
│ │ ├── +page.svelte
│ │ └── +page.ts
│ └── +layout.svelte # Minimal layouts
└── styles # Import @the-vcsi/scrolly-kit styling
Adding Stories
Create a folder in src/lib/stories/ with your story name. Add an entry to src/data/stories.csv and your story appears in the grid and gets its own route.
Story Content
Each story has a data/copy.json for text content and components/ for visualizations. The scrollytelling logic is handled by shared helpers.
Dynamic Routes
SvelteKit generates routes from CSV data at build time. The [story] folder creates pages like /geo-story-1 automatically.
Styling
Global styles in src/styles/ define CSS variables. Components use scoped styles. Check variables.css for theme customization.
Remote Functions
This project uses SvelteKit's experimental remote functions. Declare functions in a .remote.ts file, import them in Svelte components like regular functions. On the server, they work exactly like regular functions. On the client, they become wrappers around fetch—it looks like you're calling a function, but it's doing a fetch call to the backend.
import * as v from 'valibot';
import { prerender } from '$app/server';
import membersData from '$lib/data/members.csv';
import storiesData from '$lib/data/stories.csv';
import { error, redirect } from '@sveltejs/kit';
export interface Story {
slug: string;
title: string;
description: string;
author: string;
date: string;
externalUrl: string;
tags: string;
level: string;
}
const stories = storiesData as Story[];
// Glob for copy data - eager since it's small JSON
// https://vite.dev/guide/features#glob-import
const copyModules = import.meta.glob<{ default: Record<string, unknown> }>(
'$lib/stories/*/data/copy.json',
{ eager: true }
);
// Query for getting all stories
export const getStories = prerender(async () => {
return stories;
});
// Query for getting a single story by slug
export const getStory = prerender(v.string(), async (slug) => {
const story = stories.find(d => d.slug === slug);
if (!story) error(404, 'Story not found');
if (story.externalUrl) redirect(302, story.externalUrl);
// Load copy data using glob
const copyPath = `/src/lib/stories/${slug}/data/copy.json`;
const copyData = copyModules[copyPath]?.default ?? {};
return { story, copyData };
});Better Co-location
Traditional loaders separate data fetching from usage—one file loads, another consumes, often deep in the component tree. Remote functions let you import data where you need it, keeping logic together.
End-to-End Type Safety
With loaders, using +server.ts and regular fetch is completely untyped. Remote functions give you full type inference—the Story interface flows through to components automatically.
Granular Control
Loaders reload at the page level. Need data only in a corner of your page under certain conditions? With loaders, you still load it always. Remote functions give you fine-grained control over what loads when.
Secure & Intuitive
Clear boundaries mean no accidental closures or security leaks. And because they're just functions, they're intuitive to use—even LLMs understand them immediately.
// svelte.config.js
const storiesIds = storiesCSV.split('\n')
.slice(1).filter(line => line.trim())
.map(line => line.split(',')[0]);
const config = {
kit: {
prerender: {
entries: [
...storiesIds.map(id => `/${id}`)
]
},
}Experimental Remote functions are still evolving. Watch Introducing SvelteKit Remote Functions by Simon Holthausen. See here for the caveats when using arguments in prerendering mode.
