developer-onboarding-sveltekit Svelte Themes

Developer Onboarding Sveltekit

SvelteKit + Sanity CMS — Developer Code Test

Welcome. This test should take 2–3 hours for a capable developer. Read this document fully before starting.


What you're building

A minimal marketing-style site using a page builder pattern. Content is authored in a shared Sanity project; your SvelteKit app fetches it at build time and renders it as a statically generated site. The pattern mirrors our production setup directly.

Stack: SvelteKit · Sanity CMS · TailwindCSS · Vercel


Getting started

1. Fork and clone the starter repo

git clone https://github.com/droplab/developer-onboarding-sveltekit.git
cd developer-onboarding-sveltekit
pnpm install

2. Set up environment variables

Create a .env.local file in the project root. Never commit this file.

cp env.example .env.local

Fill in the values we've provided separately:

PUBLIC_SANITY_PROJECT_ID=your_project_id
PUBLIC_SANITY_DATASET=production
SANITY_API_READ_TOKEN=your_read_token

Note: The Sanity project is read-only. You cannot modify the schema or documents in the shared project — that's intentional. The bonus task (Task 4) involves a separate step if you choose to attempt it; see below.

3. Run the dev server

pnpm dev

The app will be available at http://localhost:5173. At this point you'll see a mostly empty shell — that's your starting point.


Project structure

src/
├── lib/
│   ├── sanity.ts              # Sanity client — already configured
│   ├── stores/
│   │   └── theme.svelte.ts    # Create this — global theme singleton (Task 4)
│   └── components/
│       ├── BlockRenderer.svelte   # Your main task — see Task 2
│       ├── ThemeToggle.svelte     # Create this — theme toggle button (Task 4)
│       ├── blocks/
│       │   ├── Hero.svelte        # Stub — implement in Task 3
│       │   ├── TextBlock.svelte   # Stub — implement in Task 3
│       │   └── ImageGrid.svelte   # Stub — implement in Task 3
├── routes/
│   ├── +layout.svelte         # Mount ThemeToggle, apply dark: classes, sync theme to DOM (Task 4)
│   └── [slug]/
│       ├── +page.server.ts    # Your main task — see Task 1
│       └── +page.svelte       # Wires BlockRenderer to the loaded data

Your tasks

Task 1 — Fetch page content at build time

File: src/routes/[slug]/+page.server.ts

Write a GROQ query that fetches the page document matching the current slug. Return the title and blocks array.

Requirements:

  • Use the pre-configured Sanity client from $lib/sanity
  • The page must be statically pre-rendered — add the necessary SvelteKit config to opt into SSG
  • The slug we've pre-authored content for is home — visiting /home should work once this task is complete
  • Handle the case where no document is found (return a 404, not a crash)

The Sanity document structure looks like this:

page {
  _type: "page"
  slug: { current: string }
  title: string
  blocks: array of block objects (see Task 3 for block shapes)
}

Task 2 — Implement the block renderer

File: src/lib/components/BlockRenderer.svelte

Complete the component so it maps Sanity _type values to the correct Svelte component. The stub already imports the three block components — you need to wire up the dispatch logic.

Requirements:

  • Map heroHero.svelte, textBlockTextBlock.svelte, imageGridImageGrid.svelte
  • Unknown _type values should fail gracefully — log a warning, render nothing, do not throw
  • Each block component receives its block data as a single block prop

Task 3 — Build the three block components

Each component receives its full block object as a block prop. Style with TailwindCSS. All layouts must be responsive — we check at 375px, 768px, and 1280px viewport widths.


Hero.svelte

Block shape:

{
  _type: "hero"
  heading: string
  subheading?: string
  ctaLabel?: string
  ctaUrl?: string
  backgroundImage?: SanityImageAsset
}

Requirements:

  • Full-width section, vertically centered content
  • Clear typographic hierarchy between heading and subheading
  • If both ctaLabel and ctaUrl are present, render a styled anchor tag
  • If either CTA field is missing, render nothing for the CTA — no broken links

TextBlock.svelte

Block shape:

{
  _type: "textBlock"
  body: PortableText  // Sanity Portable Text array
}

Requirements:

  • Render Portable Text using @portabletext/svelte — install it if not already present
  • Constrain the readable line width (aim for ~65–70ch max)
  • Headings, paragraph text, bulleted/numbered lists, and links must all render correctly

ImageGrid.svelte

Block shape:

{
  _type: "imageGrid"
  images: Array<{
    asset: SanityImageAsset
    alt?: string
  }>
  columns: 2 | 3 | 4
}

Requirements:

  • Use CSS grid, driven by the columns value from the block data — not hardcoded
  • Images must maintain their aspect ratio and not stretch
  • On mobile (≤ 640px), collapse to a single column regardless of the columns value
  • Use Sanity's image URL builder (@sanity/image-url) to generate image src URLs — it's already installed

Task 4 — Global state: light/dark theme

Add a persistent light/dark theme to the site. This task tests your understanding of Svelte 5's module-level runes pattern — how $state and $derived work at module scope to create a true global singleton, how $effect syncs reactive state to the DOM, and how to handle browser APIs safely in a server-rendered environment.

Files to create:

  • src/lib/stores/theme.svelte.ts — the state singleton
  • src/lib/components/ThemeToggle.svelte — the toggle button (a pure consumer of the singleton)

Files to modify:

  • src/routes/+layout.svelte — mount ThemeToggle and apply theme-aware classes to the layout wrapper
  • tailwind.config.js — enable class-based dark mode

State singleton (theme.svelte.ts)

The state must live at module scope — declared as bare $state and $derived calls at the top level of the file, outside any function. This is what makes it a true singleton: the module is evaluated once when first imported and the same reactive values are shared by every importer for the lifetime of the application.

The file extension must be .svelte.ts — runes are only valid inside .svelte and .svelte.ts files.

The structure to follow:

// src/lib/stores/theme.svelte.ts

// --- module-level $state ---
let current = $state<'light' | 'dark'>('light')

// --- module-level $derived ---
const isDark          = $derived(current === 'dark')
const label           = $derived(isDark ? 'Dark' : 'Light')
const icon            = $derived(isDark ? '🌙' : '☀️')
const oppositeLabel   = $derived(`Switch to ${isDark ? 'light' : 'dark'} mode`)

// Export a plain object with getters so reactive values are tracked
// correctly by Svelte's compiler at the call site
export const theme = {
  get current()       { return current },
  get isDark()        { return isDark },
  get label()         { return label },
  get icon()          { return icon },
  get oppositeLabel() { return oppositeLabel },

  toggle() { current = isDark ? 'light' : 'dark' },
  set(value: 'light' | 'dark') { current = value },
}

Important: do not wrap $state / $derived in a createTheme() factory function that gets called from the component — that creates a fresh instance per call site, not a singleton.


Initialisation and DOM sync (+layout.svelte)

Use $effect inside +layout.svelte (not in the module) to handle the two browser-only side effects:

  1. Apply the dark class to <html> — Tailwind's class-based dark mode requires document.documentElement.classList to be toggled whenever theme.isDark changes
  2. Persist to localStorage — write theme.current to localStorage on every change so the preference survives a page reload

Also in onMount (or an $effect guarded for client-only execution), read the initial value from localStorage if present, otherwise fall back to the prefers-color-scheme media query. Call theme.set() with the resolved value.

SSR safety: localStorage, document, and window do not exist on the server. Any code that references them must only run in the browser. An unguarded reference will crash the server-side render. Think carefully about where and how you initialise.


Toggle component (ThemeToggle.svelte)

ThemeToggle.svelte is a pure consumer — it imports theme and renders its values. It owns no state of its own.

Requirements:

  • Render a <button> displaying theme.icon and theme.label
  • Set aria-label to theme.oppositeLabel
  • Call theme.toggle() on click
  • No local $state, no $effect — just reads and a single event handler

TailwindCSS dark mode

In tailwind.config.js, set:

darkMode: 'class'

Then use dark: variants on any elements you want to theme — the layout wrapper, block components, or both. At minimum the page background and primary text colour should respond to the theme.


Requirements

  • $state and $derived are declared at module scope in theme.svelte.ts — not inside a function
  • The exported theme object uses getters for all reactive values
  • ThemeToggle.svelte contains no $state or $effect — all values come from the singleton
  • The DOM sync (classList toggle + localStorage write) happens in $effect inside +layout.svelte, not in the module
  • Initialisation reads from localStorage / prefers-color-scheme and only runs in the browser
  • The chosen theme persists across a hard page reload
  • No Svelte 4 stores (writable, readable, derived from svelte/store), no getContext / setContext — runes only

Task 5 (bonus) — Add a testimonial block end-to-end

Only attempt this if you have time remaining.

You'll need your own Sanity account for this task, as you cannot modify the shared project schema.

  1. Create a free Sanity project at sanity.io
  2. Define a testimonial schema type with these fields:
{
  _type: "testimonial"
  quote: string           // required
  attribution: string     // required
  avatar?: SanityImage    // optional
}
  1. Add a testimonial document to your dataset with real content
  2. Build Testimonial.svelte and register it in BlockRenderer
  3. Point your .env at your own project ID and dataset
  4. The Vercel deployment (Task 6) should use your project

Task 6 — Deploy to Vercel

Requirements:

  • Connect your GitHub fork to a Vercel project
  • Set the three environment variables in the Vercel dashboard (Settings → Environment Variables)
  • The build must complete successfully with @sveltejs/adapter-vercel in static output mode
  • Submit a working live URL — we'll visit it to verify

Submission checklist

Before sending back:

  • /home route renders all three blocks from Sanity content
  • Layouts look correct at 375px, 768px, and 1280px
  • Portable Text renders headings, lists, and links correctly
  • Image grid columns respond to the columns field value
  • Unknown block types fail silently (no crashes)
  • SANITY_API_READ_TOKEN is not committed to the repo
  • Theme persists across a hard page reload (localStorage)
  • Theme initialises from localStorage or prefers-color-scheme on first visit
  • All derived values (isDark, label, icon, oppositeLabel) live in the singleton — not computed in the template
  • Theme singleton is a single module-level export, not recreated per component
  • No unguarded browser API references that would crash the SSR pass
  • Vercel deployment builds and the live URL works
  • NOTES.md is included (see below)

Send us:

  1. Link to your GitHub fork (public, or add [your-github-handle] as a collaborator)
  2. Live Vercel URL
  3. Your NOTES.md

NOTES.md

Include a brief NOTES.md in the root of your repo. It doesn't need to be long — a few bullet points covering:

  • Any non-obvious decisions you made and why
  • Anything you'd do differently with more time
  • Any part of the brief you interpreted differently than intended

We read these carefully. Clear thinking in writing is as important to us as clean code.


Packages already installed

Package Purpose
@sanity/client Sanity API client
@sanity/image-url Image URL builder
@portabletext/svelte Portable Text renderer
tailwindcss Utility CSS
@sveltejs/adapter-vercel Vercel deployment adapter

Code standards & formatting

These are the conventions we follow in production. We'll be looking for alignment with them in your submission.


Allman brace style

Use Allman style for all blocks — opening braces on their own line, not inline.

function greet(name: string)
{
  return `Hello, ${name}`
}

if (condition)
{
  doSomething()
}
else
{
  doSomethingElse()
}

This applies to functions, conditionals, loops, and class bodies. Avoid K&R / one-true-brace style.


TypeScript — keep it light

TypeScript is there to catch mistakes, not to become an obstacle. Follow these guidelines:

  • No generics unless absolutely necessary. If the type can be inferred, let it be inferred. Explicit generics are only warranted when inference would produce unknown, any, or an ambiguous union that would actually cause a bug.
  • Prefer simple, concrete types. string, number, boolean, and plain object shapes cover most cases. Reach for utility types (Partial, Pick, ReturnType, etc.) only when they genuinely reduce duplication.
  • Avoid over-engineering interfaces. A single-use inline type is fine. A full interface declaration is only worth it if the shape is reused in multiple places.

The goal is code that reads clearly without a TypeScript PhD — if a type annotation is making the code harder to read, it's probably not needed.


Single-purpose functional components

Keep components focused on one job. If a component is doing two unrelated things, split it.

  • Each component should have a clear, single responsibility that fits in its name
  • Prefer small, composable units over large, multi-concern blobs
  • Avoid mixing data-fetching logic, state management, and rendering concerns in the same component — separate them where it makes sense. Lean on re-usable helpers and utilities where applicable (e.g data fetching, re-usable UI components, etc...)

DRY — Don't Repeat Yourself

If you find yourself writing the same markup, logic, or style in more than one place, extract it:

  • Repeated markup → a new component
  • Repeated logic → a utility function in $lib
  • Repeated style patterns → a Tailwind @apply class or a shared wrapper component

One copy of the truth, referenced everywhere it's needed.


AI-generated code

Using AI tools to help write code is fine — we use them too. What matters is what you do with the output.

Raw AI-generated code is a starting point, not a final answer. Before committing anything AI-assisted:

  • Read it. If you can't explain what every line does, it shouldn't ship.
  • Optimise it. AI output often over-engineers simple problems or misses obvious shortcuts. Cut what isn't needed.
  • Format it. Apply the conventions in this section — Allman style, clean TypeScript, single-purpose components. AI tools rarely follow project-specific conventions without being told, and it's your responsibility to correct that before submission.
  • Don't paste verbatim. Copy-pasted output without review is visible in a code review. It tends to look generic, carry unnecessary abstractions, and ignore the patterns already established in the codebase.

We're not looking for code that was written without help — we're looking for code that reflects clear thinking and good judgement, regardless of how it got there.


Tips

  • Check src/lib/sanity.ts before writing your GROQ query — the client is already initialised with the project ID and dataset from your .env
  • SvelteKit's prerender export and load functions in +page.server.ts are the right tools for Task 1 — docs here
  • The @sanity/image-url builder takes a client instance and an asset reference — the pattern is urlFor(image).width(800).url()
  • Tailwind's grid-cols-{n} utilities work well for the image grid, but you'll need to drive the column count dynamically from the block data
  • For Task 4, the runes-based singleton pattern lives in a .svelte.ts file — $state and $derived are only reactive inside .svelte or .svelte.ts files, not plain .ts files
  • The SSR-safe pattern for reading localStorage on first load is onMount or an $effect — both only run in the browser, never on the server

Questions

If anything in this brief is genuinely unclear, reach out before spending time on the wrong interpretation. We'd rather answer a question than review a submission that went off-track.

Good luck!

Top categories

Loading Svelte Themes