January 2026
Building Our Website with Jaspr: A Pure Dart Journey
By Bilal Aksoy
When we decided to rebuild our company website, we wanted to stay true to our Dart-first philosophy. We'd been building Flutter apps for years, and the idea of writing JavaScript for our website felt wrong. Enter Jaspr, a modern web framework that lets you build websites entirely in Dart.
We thought it would be straightforward. Write some components, run jaspr serve, done. Then our scroll listener crashed the build.
The Moment It Clicks: Your Code Runs Twice
Picture this: You're building a navigation bar that needs to change color when the user scrolls. Simple enough, right? You add a scroll listener in your component:
@override
void initState() {
super.initState();
web.window.addEventListener('scroll', _onScroll);
}
You run jaspr serve and the build crashes. Not at runtime in the browser. During the build itself. The error message mentions something about
web.window not being available.
This was our first encounter with Jaspr's dual environment, and it took us a while to understand what was happening. Your code runs in two completely different places.
The Two Environments
-
Server-side (Build Time): When you run
jaspr build, your Dart code runs on the server to generate static HTML. It's pre-rendering everything. -
Client-side (Browser): After the HTML loads in the browser, specific components marked with
@clientare "hydrated". They become interactive by running the Dart code compiled to JavaScript.
This dual nature is powerful, but it has a big implication: code that works perfectly in the browser will crash during server-side rendering if it tries to access browser APIs like
window or document.
Our Evolution
Our first attempt used dart:js_interop directly. Crashed on the server.
Our second attempt used universal_web, which provides cross-platform stubs that work on both server and client:
import 'package:universal_web/js_interop.dart';
import 'package:universal_web/web.dart' as web;
But we still needed to guard browser-specific code with kIsWeb checks:
// navigation.dart
@override
void initState() {
super.initState();
// Only setup scroll listener on client
if (kIsWeb && !component.alwaysLight) {
_setupScrollListener();
}
}
void _setupScrollListener() {
_scrollCallback = _onScroll.toJS;
web.window.addEventListener('scroll', _scrollCallback);
_checkScroll(); // Initial check
}
This pattern became our mantra: always wrap browser APIs in kIsWeb checks. The server can't access window, and trying to do so will crash your build.
We Started With Three @client Components (And That's All We Needed)
Once we understood the dual environment, we had to decide which components needed client-side hydration. This is the core question of Jaspr's islands architecture: which pieces of your site need JavaScript, and which can be pure static HTML?
We ended up with about 20 components in our site. Of those, only 3 needed the @client annotation:
- Navigation (scroll-based color switching and mobile menu toggle)
- TechScroller (interactive horizontal scrolling based on cursor position)
- ContactForm (form validation and mailto link generation)
Everything else is static HTML. Our hero section, about section, services cards, team profiles, blog layouts, footer. No JavaScript, no hydration, just HTML and CSS.
This is the islands architecture in action. You have a sea of static content with small islands of interactivity. Each island is a
@client component that gets hydrated independently. The result is a tiny JavaScript bundle and fast initial page loads.
Component War Stories
Let me walk you through the real challenges we encountered building these three interactive components. These are the actual bugs and gotchas we ran into.
Navigation: The Memory Leak We Almost Had
The navigation bar was our first @client component. It needed to listen to scroll events and toggle between transparent and white backgrounds.
We got the scroll listener working, but then we realized something. What happens when the component is removed from the DOM? Event listeners don't clean themselves up. If we didn't remove the listener, we'd have a memory leak every time the component was destroyed and recreated.
The fix required storing a reference to the JavaScript function so we could remove it later:
// navigation.dart
class _NavigationState extends State<Navigation> {
JSFunction? _scrollCallback; // Store reference for cleanup
@override
void initState() {
super.initState();
if (kIsWeb && !component.alwaysLight) {
_setupScrollListener();
}
}
void _setupScrollListener() {
_scrollCallback = _onScroll.toJS; // Convert Dart function to JS
web.window.addEventListener('scroll', _scrollCallback);
_checkScroll();
}
@override
void dispose() {
// CRITICAL: Remove listener to prevent memory leak
if (_scrollCallback != null) {
web.window.removeEventListener('scroll', _scrollCallback);
}
super.dispose();
}
}
This is basic React-style lifecycle management, but it's easy to forget. The symptom of forgetting is your site gets progressively slower as orphaned event listeners pile up in memory.
Always clean up browser event listeners in dispose(), and store the JSFunction
reference so you can remove the exact listener you added.
TechScroller: The Hydration Race Condition
Our tech scroller displays a horizontal list of technologies with a cool auto-scroll effect when you hover near the edges. To make the infinite loop seamless, we needed to access the DOM element and manipulate its
scrollLeft property.
We used GlobalNodeKey for type-safe DOM access:
// tech_scroller.dart
final GlobalNodeKey<web.HTMLDivElement> _scrollContainerKey = GlobalNodeKey();
@override
Component build(BuildContext context) {
return div(
key: _scrollContainerKey, // Attach key to div
// ... rest of component
);
}
Then we could reference the actual DOM element:
void _startAutoScroll() {
_autoScrollTimer = Timer.periodic(const Duration(milliseconds: 20), (timer) {
final container = _scrollContainerKey.currentNode; // Get DOM element
if (container == null) return;
container.scrollLeft = container.scrollLeft + scrollAmount;
// Seamless infinite loop: jump back when hitting the duplicate
if (scrollAmount > 0 && container.scrollLeft >= singleSetWidth) {
container.scrollLeft = container.scrollLeft - singleSetWidth;
}
});
}
But here's the gotcha: we initially tried to access currentNode immediately in initState(). It was always
null. Why? Hydration isn't instant. The DOM element doesn't exist yet when initState()
runs.
The fix was to delay our DOM access slightly:
@override
void initState() {
super.initState();
if (kIsWeb) {
// Give hydration time to complete
Future.delayed(const Duration(milliseconds: 100), _initializeScrollPosition);
}
}
void _initializeScrollPosition() {
final container = _scrollContainerKey.currentNode;
if (container == null) return; // Still check, but now it exists
container.scrollLeft = 0;
}
GlobalNodeKey gives you type-safe DOM access, but hydration takes time. Use Future.delayed()
for initial DOM operations, and always null-check currentNode.
Bonus discovery: To create the seamless infinite scroll, we duplicated our content list in the component's children. When the scroll position passes the halfway point, we jump back to the start. Because the content is duplicated, the user doesn't see the jump.
ContactForm: No Backend, No Problem
We needed a contact form, but we didn't want to set up a backend just for form submissions. Our solution? Generate a
mailto: link with the form data pre-populated.
When the user submits the form, we validate the fields and then open their default email client:
// contact_form.dart
form(
events: {
'submit': (event) {
event.preventDefault(); // Don't reload the page
if (formState.isValid) {
notifier.setSubmitting(true);
// Open email client with pre-filled data
web.window.location.href = formState.mailtoUri.toString();
// Reset after short delay
Future.delayed(const Duration(milliseconds: 500), () {
notifier.setSubmitting(false);
});
}
},
},
[/* form fields */],
)
The mailtoUri is constructed from the form state:
// contact_form_provider.dart
Uri get mailtoUri => Uri(
scheme: 'mailto',
path: 'info@gbot.dev',
query: 'subject=${Uri.encodeComponent(subject)}&body=${Uri.encodeComponent(mailtoBody)}',
);
Simple validation checks that all fields are filled and the email contains an @:
bool get isValid =>
name.trim().isNotEmpty &&
email.trim().isNotEmpty &&
email.contains('@') &&
subject.trim().isNotEmpty &&
message.trim().isNotEmpty;
Not every form needs a backend. For simple contact forms on static sites, mailto: links work well. Just remember to
Uri.encodeComponent() the subject and body to handle special characters.
The Content System: When We Stopped Writing HTML
About halfway through the project, we discovered jaspr_content.
Initially, we were hand-coding every page as Dart components. Want to add a blog post? Write a whole new component. Want to add a team member? Another component. It was tedious, and only developers could add content.
Then we found jaspr_content's ContentApp.custom. It automatically generates routes from markdown files with YAML frontmatter. Here's our setup:
// main.server.dart
ContentApp.custom(
loaders: [
FilesystemLoader('content'), // Scan content/ directory
],
configResolver: PageConfig.all(
parsers: [
MarkdownParser(), // Parse YAML frontmatter + markdown
],
layouts: [
const GbotBlogLayout(), // Custom layout for blog posts
const GbotTeamLayout(), // Custom layout for team profiles
const GbotProjectLayout(), // Custom layout for projects
],
),
// ... router configuration
)
Now adding a blog post is as simple as creating a markdown file:
---
title: 'Building Our Website with Jaspr: A Pure Dart Journey'
date: January 2026
author: Bilal Aksoy
description: War stories from building a production Jaspr site...
layout: blog // This tells jaspr_content which layout to use
---
Your markdown content here...
Drop it in content/blog/my-post.md, and jaspr_content automatically:
- Parses the YAML frontmatter
- Converts the markdown to HTML
- Generates a route at
/blog/my-post - Wraps it in the
GbotBlogLayoutspecified in the frontmatter
Custom Layouts
Custom layouts extend PageLayoutBase and wrap the markdown content with your site's navigation, footer, and styling:
// blog_layout.dart
class GbotBlogLayout extends PageLayoutBase {
const GbotBlogLayout();
@override
Pattern get name => 'blog'; // Matches frontmatter layout: blog
@override
Component buildBody(Page page, Component child) {
// Extract frontmatter data
final title = page.data.page['title'] as String?;
final date = page.data.page['date'] as String?;
final author = page.data.page['author'] as String?;
return div([
const Navigation(alwaysLight: true),
article([
// Header section with frontmatter data
div([/* title, date, author */]),
// The markdown content as 'child'
div(classes: 'blog-content prose', [child]),
]),
const Footer(),
]);
}
}
The beauty of this system is that the islands architecture extends to content. The blog post itself is static markdown converted to HTML at build time. The only interactive parts are the three
@client components (Navigation, etc.) embedded in the layout.
Start with jaspr_content if you need any content management. It's easier to add static routes later than to retrofit content management after you've hand-coded everything.
Riverpod: Familiar but Not Required
We used Riverpod for state management in our interactive components. This wasn't strictly necessary (we could have used
setState()), but we knew Riverpod from Flutter, and we were curious how it worked in Jaspr.
The pattern is familiar: immutable state with copyWith:
// navigation_provider.dart
class NavigationState {
const NavigationState({
this.isScrolled = false,
this.isMobileMenuOpen = false,
});
final bool isScrolled;
final bool isMobileMenuOpen;
NavigationState copyWith({bool? isScrolled, bool? isMobileMenuOpen}) {
return NavigationState(
isScrolled: isScrolled ?? this.isScrolled,
isMobileMenuOpen: isMobileMenuOpen ?? this.isMobileMenuOpen,
);
}
}
And a notifier with methods that mutate state:
class NavigationNotifier extends Notifier<NavigationState> {
@override
NavigationState build() => const NavigationState();
void setScrolled(bool scrolled) {
// Guard against redundant updates
if (state.isScrolled != scrolled) {
state = state.copyWith(isScrolled: scrolled);
}
}
}
The guard check (if (state.isScrolled != scrolled)) prevents unnecessary re-renders. We found this optimization important in high-frequency event handlers like scroll listeners.
In components, you watch the provider for reactive updates:
// Watch for reactive rebuilds
final navState = context.watch(navigationProvider);
// Read notifier for imperative actions
context.read(navigationProvider.notifier).setScrolled(scrolled);
Was Riverpod worth it? For this small site, probably not. The three @client components could have easily used
setState(). But Riverpod felt natural coming from Flutter, it made testing easier (we could test providers in isolation), and it kept our components clean by moving state logic out to dedicated provider classes.
Riverpod isn't required for Jaspr, but it's a nice-to-have if you're already familiar with it. For simple interactive components,
setState() is perfectly fine.
Styling with Tailwind CSS
Jaspr has first-class support for Tailwind CSS through the jaspr_tailwind package. This was one of the smoothest parts of the whole setup.
Add it to your pubspec.yaml:
dependencies:
jaspr_tailwind: ^0.3.6
Then use Tailwind classes in your components:
div(
classes: 'flex items-center justify-between p-4 bg-gray-900 rounded-lg',
[/* children */],
)
The jaspr_tailwind package watches your Dart files and automatically generates the CSS for the classes you use. No separate build step, no configuration headaches.
Custom Styles When Needed
Sometimes Tailwind classes aren't enough. Jaspr's Styles API lets you add custom CSS properties:
div(
classes: 'flex items-center', // Tailwind for common patterns
styles: Styles(
raw: {
'font-family': 'Quicksand, sans-serif', // Custom when needed
'letter-spacing': '0.6px',
},
),
[/* children */],
)
We found this combination powerful: Tailwind for rapid prototyping and common patterns, custom styles for brand-specific details.
Deployment: Why We Wrote Three Shell Scripts
When we pushed our first build to Cloudflare Pages, it failed. The build log showed: dart: command not found. Of course. Cloudflare Pages provides a minimal Ubuntu container. No Dart SDK pre-installed.
We couldn't rely on system-installed tools. We needed to download and configure everything ourselves. We wrote three modular shell scripts:
setup.sh - Download Dart SDK
#!/bin/bash
set -e
echo "Downloading Dart SDK..."
wget -q https://storage.googleapis.com/dart-archive/channels/stable/release/3.10.0/sdk/dartsdk-linux-x64-release.zip
unzip -q dartsdk-linux-x64-release.zip
export PATH="$PATH:$PWD/dart-sdk/bin"
tailwind.sh - Download Tailwind CLI
#!/bin/bash
set -e
echo "Downloading Tailwind CSS..."
wget -q https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64
chmod +x tailwindcss-linux-x64
export PATH="$PATH:$PWD"
Why download Tailwind directly instead of using npm? The jaspr_tailwind package needs the Tailwind CLI binary, and downloading it directly avoids the Node.js overhead.
build.sh - Orchestrate Everything
#!/bin/bash
set -e
# Source scripts to inherit PATH exports
source ./scripts/setup.sh
source ./scripts/tailwind.sh
# Install dependencies and build
echo "Installing Dart dependencies..."
dart pub get
echo "Activating Jaspr CLI..."
dart pub global activate jaspr_cli
echo "Building site..."
jaspr build
echo "Build complete! Output is in build/jaspr/"
The key insight: using source instead of just executing the scripts means we inherit their
PATH exports. Without source, the PATH changes wouldn't persist.
Cloudflare Pages Configuration
Build command:
chmod +x scripts/*.sh && ./scripts/build.sh
Build output directory:
build/jaspr
The chmod +x is necessary because git doesn't preserve execute permissions reliably across platforms. Windows users pushing to the repo won't have the execute bit set, so we set it explicitly in the build command.
Static site deployment on platforms like Cloudflare Pages requires explicit dependency management. The modular script approach makes it easy to test locally and ensures reproducible builds.
Conclusion
Building our website with Jaspr was a journey of discovery. We started thinking it would be "Flutter for web" and quickly learned it's something different.
Some things we learned:
-
Understanding that your code runs server-side AND client-side is crucial. Wrap browser APIs in
kIsWebchecks and useuniversal_web. -
Start by making everything static. Only add
@clientwhen you truly need interactivity. We ended up with 3 interactive components out of 20+ total. Don't hand-code content pages. Use markdown files with frontmatter and custom layouts.
-
Event listeners need
dispose()cleanup. DOM access needs null checks and hydration delays. -
Platforms like Cloudflare Pages don't have Dart pre-installed. Write scripts to download what you need.
Would we use Jaspr again? Absolutely. The ability to write pure Dart, get type-safe refactoring, and ship minimal JavaScript is compelling. The learning curve exists, but once you understand the mental model, it's enjoyable to work with.
By Bilal Aksoy • January 2026
← Back to Blog