Custom Research Website

A maintainable, reusable and extensible website template to tell your stories.

Static template
npx degit Vermont-Complex-Systems/vcsi-starter/templates/baked example
Dynamic template
npx degit Vermont-Complex-Systems/vcsi-starter/templates/fresh example
For installation details, features, and more visit GitHub
Loading stories...

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.

src/lib/story.remote.ts
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.

Small Caveat: we need to say which routes are dynamic
// 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.