This repository builds the same counter application twice -- once with plain HTML, CSS, and JavaScript, and once with SvelteKit (Svelte 5). Both produce an identical UI: a heading, a large number display, and three buttons (-1, Reset, +1). By comparing the two side by side, we can see exactly what a modern framework gives you and what it costs.
npm install # installs root deps + both sub-projects
npm run dev # runs both apps concurrently
The vanilla app is served by live-server; the SvelteKit app is served by Vite.
This is the most important difference between the two approaches.
State is a plain JavaScript variable declared inside an inline <script> tag. Every time the value changes, the developer must explicitly push the new value into the DOM:
let count = 0;
const display = document.getElementById("count");
const update = () => {
display.textContent = count;
};
document.getElementById("increment").addEventListener("click", () => {
count++;
update(); // forget this line and the UI is stale
});
Key characteristics:
textContent.update() calls grows with it. Missing one call produces a bug that is silent and hard to catch.State is declared with Svelte 5's $state rune. The framework tracks which parts of the DOM depend on that state and updates them automatically:
<!-- Counter.svelte -->
<script>
import CounterDisplay from './CounterDisplay.svelte';
import CounterButton from './CounterButton.svelte';
let count = $state(0);
</script>
<CounterDisplay value={count} />
<CounterButton label="+1" onclick={() => count++} />
Key characteristics:
count is the state, and every place that reads it re-renders automatically when it changes. There is no separate sync step.CounterDisplay receives value via $props() and simply renders it. The display component does not need to know where the value came from or how it was updated.CounterButton receives an onclick handler as a prop. The button fires it; the parent decides what happens. This keeps child components stateless and reusable.$state() declarations. The framework handles the rest.| Aspect | Vanilla | SvelteKit |
|---|---|---|
| Declaration | let count = 0 |
let count = $state(0) |
| Reading state | display.textContent |
{count} in template |
| Mutating state | count++; update() |
count++ |
| Passing state down | n/a (single file) | Props via $props() |
| DOM sync | Manual (update()) |
Automatic (compiler-generated) |
| Risk of stale UI | High (forget update()) |
None (compiler guarantees sync) |
The entire app lives in one HTML file (vanilla/app.html) with an external stylesheet. Markup, logic, and event wiring are all co-located in one place:
vanilla/
├── app.html ← markup + inline <script>
├── app.css ← all styles
├── package.json
└── package-lock.json
This is the simplest possible architecture. There are no imports, no modules, no components. For a counter this is fine -- for anything larger, the file would grow into a monolith.
The app is split into focused, single-responsibility components:
svelte/src/
├── app.html ← shell template
├── app.css ← global reset + design tokens
├── routes/
│ ├── +layout.svelte ← root layout (fonts, global CSS)
│ └── +page.svelte ← renders <Counter />
└── lib/
├── Counter.svelte ← stateful parent
├── CounterDisplay.svelte ← presentational (shows value)
└── CounterButton.svelte ← presentational (fires onclick)
The component tree looks like this:
+page.svelte
└── Counter (owns state)
├── CounterDisplay (reads value prop)
└── CounterButton x3 (fires onclick prop)
Each component encapsulates its own markup, logic, and scoped styles. Adding a new page means adding a new +page.svelte file -- routing is automatic.
Both approaches share the same design tokens (CSS custom properties) and the same CSS Cascade Layers for controlling specificity.
All styles live in a single app.css with four layers:
@layer reset, base, layout, components;
Every selector is global. Button variants are targeted by ID (#reset), and layout classes like .container and .buttons exist in a flat namespace. In a larger project, naming collisions become a real concern.
The global app.css is trimmed to just two layers (reset, base) for the universal reset and design tokens. Everything else moves into scoped <style> blocks inside each component:
<!-- CounterButton.svelte -->
<style>
button {
/* these rules only apply to <button> inside this component */
background: var(--color-btn);
}
button.alt {
background: var(--color-btn-alt);
}
</style>
Svelte scopes these styles at compile time by adding unique class attributes, so button in CounterButton.svelte will never leak into other components. You can write simple, semantic selectors without worrying about collisions.
| Aspect | Vanilla | SvelteKit |
|---|---|---|
| Scope | Global | Scoped per component |
| Layer count | 4 (reset, base, layout, components) |
2 global (reset, base) + scoped |
| Naming risk | Collisions possible | No collisions (compiler-scoped) |
| Colocation | Separate file | <style> lives with its component |
"devDependencies": {
"live-server": "^1.2.2"
}
That is the entire toolchain. live-server watches for file changes and reloads the browser. There is no bundler, no transpiler, no minifier. The browser receives exactly the files you wrote.
Pros: zero configuration, instant startup, nothing to debug in the build layer.
Cons: no module system (no import/export), no tree-shaking, no code-splitting, no TypeScript, no asset hashing for cache busting.
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"svelte": "^5.51.0",
"vite": "^7.3.1"
}
The SvelteKit build pipeline includes:
.svelte files into optimised JavaScript at build time.Pros: module system, tree-shaking, code-splitting, TypeScript support, asset hashing, SSR, adapters for any hosting platform. Cons: more dependencies, more configuration surface, longer initial setup, build errors are possible.
The counter is trivial. The real question is: what happens when the app grows?
| Concern | Vanilla | SvelteKit |
|---|---|---|
| Adding pages | Create new HTML files, manually link between them, duplicate shared markup | Add a +page.svelte file -- routing is automatic |
| Shared state | Global variables on window, or hand-rolled pub/sub |
Svelte stores, context API, or shared $state modules |
| Data fetching | fetch() in <script>, manual loading/error states |
+page.server.js load functions with built-in loading UI |
| Forms | Manual FormData, manual validation, manual error display |
SvelteKit form actions with progressive enhancement |
| Code reuse | Copy-paste or <template> element cloning |
Import components, pass props |
| Testing | Attach test scripts to the global scope | Standard module imports, component testing with Vitest |
In vanilla, every new feature requires you to build the plumbing yourself. In SvelteKit, the framework provides the plumbing and you focus on the feature.
<script>. There is nothing to download, parse, or initialise beyond your own code.textContent assignments -- the fastest possible mutation.innerHTML) becomes a performance bottleneck. You would need to implement your own diffing or virtualisation.For a counter, both approaches are instantaneous. At scale, SvelteKit's compiled output stays fast without manual optimisation, while vanilla requires increasing effort to maintain performance.
| Aspect | Vanilla | SvelteKit |
|---|---|---|
| Setup time | Seconds (create an HTML file) | Minutes (npm create svelte, install deps) |
| Learning curve | Just HTML/CSS/JS -- nothing new | Svelte syntax, runes, SvelteKit conventions |
| Editor support | Basic HTML/CSS/JS intellisense | Svelte language server with autocomplete, diagnostics, go-to-definition |
| Hot reload | Full page reload via live-server |
HMR via Vite (preserves component state) |
| Accessibility | No built-in checks | Compiler warns about missing alt text, label associations, etc. |
| Type safety | None (unless you add JSDoc) | Optional TypeScript with full inference across components |
| Debugging | Browser DevTools, console.log |
Browser DevTools + Svelte DevTools extension for inspecting component state |
| Error messages | Runtime errors only | Compile-time errors catch mistakes before the browser |
Choose vanilla HTML/CSS/JS when:
Choose SvelteKit when:
| Dimension | Vanilla | SvelteKit |
|---|---|---|
| State management | Manual, imperative | Automatic, reactive ($state) |
| DOM updates | Explicit (textContent, innerHTML) |
Compiler-generated, surgical |
| Architecture | Single file, flat | Component tree, file-based routing |
| Styling | Global CSS | Global base + scoped component styles |
| Build step | None | Vite (HMR, bundling, tree-shaking) |
| Dependencies | 1 (live-server) |
5 (Svelte, SvelteKit, Vite, adapter, plugin) |
| Scalability | Low (manual plumbing) | High (framework handles plumbing) |
| Performance baseline | Excellent (zero overhead) | Excellent (compiles to vanilla JS) |
| Performance at scale | Degrades without manual work | Stays fast (optimised compilation) |
| Learning curve | Low (just the platform) | Moderate (framework concepts) |
| Best for | Simple/static pages, learning | Interactive apps, growing codebases |