Skip to content

SPA com Roteamento

Este exemplo demonstra como criar uma Single Page Application (SPA) usando o roteador do Slash: navegação client-side, route params, query strings, guards e um fallback 404.

  • ✅ Roteamento client-side (SPA)
  • ✅ Navegação com <Link> e programática
  • ✅ Route params e query strings
  • ✅ Navigation guards (redirecionam com uma string de caminho)
  • ✅ Fallback 404
  • ✅ Hash mode e History mode (em hash mode, links diretos como /#/products?category=books funcionam)
src/
├── index.ts # Entry point
├── router.ts # Configuração do router
├── pages/
│ ├── Home.ts # Página inicial
│ ├── Products.ts # Lista de produtos
│ ├── ProductDetail.ts # Detalhe do produto
│ ├── Dashboard.ts # Dashboard (protegido)
│ ├── Login.ts # Login
│ └── NotFound.ts # 404
├── components/
│ ├── Layout.ts # Layout base
│ └── Header.ts # Header com navegação
├── services/
│ └── auth.ts # Serviço de autenticação
└── types.ts # Tipos
src/types.ts
export interface User {
id: string
name: string
email: string
role: 'user' | 'admin'
}
export interface Product {
id: string
name: string
description: string
price: number
category: string
image: string
}
export interface RouteParams {
id?: string
[key: string]: string | undefined
}
src/services/auth.ts
import { createState } from '@_bashell/slash'
import type { User } from '../types'
type AuthState = {
user: User | null
isAuthenticated: boolean
isLoading: boolean
}
// Lê o usuário salvo de forma síncrona: o guard roda na primeira navegação, que
// começa assim que o router é criado. O estado precisa estar pronto antes disso.
const loadSavedAuth = (): AuthState => {
try {
const saved = localStorage.getItem('user')
if (saved) {
return { user: JSON.parse(saved) as User, isAuthenticated: true, isLoading: false }
}
} catch {
// JSON inválido ou localStorage indisponível: segue como visitante
}
return { user: null, isAuthenticated: false, isLoading: false }
}
// Estado de autenticação (já carregado do localStorage)
export const authState = createState<AuthState>(loadSavedAuth())
// Login
export const login = async (email: string, password: string): Promise<void> => {
// Simula API call
await new Promise(resolve => setTimeout(resolve, 1000))
// Mock user
const user: User = {
id: '1',
name: 'John Doe',
email,
role: 'user'
}
localStorage.setItem('user', JSON.stringify(user))
authState.set({
user,
isAuthenticated: true,
isLoading: false
})
}
// Logout
export const logout = (): void => {
localStorage.removeItem('user')
authState.set({
user: null,
isAuthenticated: false,
isLoading: false
})
}
// Check if user has role
export const hasRole = (role: string): boolean => {
const state = authState.get()
return state.isAuthenticated && state.user?.role === role
}

As rotas são funções component(state) que recebem o RouterState (com params, query, meta) e devolvem um template. Cada rota monta a página como componente (<${Page} />), para que a página re-renderize quando os states que ela lê mudarem. Guards devolvem true/void para permitir, false para bloquear ou uma string com o caminho de redirecionamento:

src/router.ts
import { html } from '@_bashell/slash'
import { createRouter, type NavigationGuard, type RouteConfig } from '@_bashell/slash/router'
import { authState } from './services/auth'
import { Home } from './pages/Home'
import { Products } from './pages/Products'
import { ProductDetail } from './pages/ProductDetail'
import { Dashboard } from './pages/Dashboard'
import { Login } from './pages/Login'
import { NotFound } from './pages/NotFound'
// Guard de autenticação: redireciona para /login guardando o destino na query
const authGuard: NavigationGuard = (to) => {
if (!authState.get().isAuthenticated) {
return `/login?redirect=${encodeURIComponent(to.path)}`
}
return true
}
const routes: RouteConfig[] = [
{ path: '/', component: () => html`<${Home} />`, meta: { title: 'Home' } },
{ path: '/products', component: (state) => html`<${Products} category=${state.query.category ?? 'all'} />`, meta: { title: 'Products' } },
{ path: '/products/:id', component: (state) => html`<${ProductDetail} id=${state.params.id} />`, meta: { title: 'Product Details' } },
{
path: '/dashboard',
component: () => html`<${Dashboard} />`,
meta: { title: 'Dashboard', requiresAuth: true },
guards: [authGuard]
},
{ path: '/login', component: (state) => html`<${Login} redirect=${state.query.redirect ?? '/dashboard'} />`, meta: { title: 'Login' } },
{ path: '/404', component: () => html`<${NotFound} />`, meta: { title: '404 Not Found' } }
]
export const router = createRouter({
mode: 'history', // ou 'hash': a URL fica /#/products e a rota inicial vem de location.hash
routes,
fallback: '/404' // caminho usado quando nenhuma rota casa
})
// Observar a navegação: atualiza o título da página
router.watch((state) => {
const title = state.currentRoute?.meta?.title
if (title) document.title = `${title} - My SPA`
})

O guard também roda na primeira navegação, quando o usuário abre /dashboard direto pela barra de endereço ou por um link salvo. Enquanto ele decide, o estado do router é currentRoute: null, isNavigating: true, então a página protegida não é renderizada nem aparece por um instante (sem flash). Depois da decisão vem a rota pedida, o destino do redirect (aqui /login?redirect=...) ou, se o guard devolver false, currentRoute: null: trate esse caso com uma tela vazia ou de erro.

Se você precisar esperar a navegação inicial terminar (por exemplo, para medir ou para só então esconder um splash), use await router.ready:

import { router } from './router'
await router.ready // resolve quando a navegação inicial termina (redirects incluídos)
console.log(router.get().currentRoute?.path)

Cada navegação segue no máximo 10 redirects encadeados, e uma navegação mais nova substitui uma anterior ainda pendente.

O header lê authState e router (que é um State), então re-renderiza quando o usuário loga ou quando a rota muda. Link exige a prop router:

src/components/Header.ts
import { html } from '@_bashell/slash'
import { Link } from '@_bashell/slash/router'
import { router } from '../router'
import { authState, logout } from '../services/auth'
export const Header = () => {
// Estes get() inscrevem o componente
const auth = authState.get()
const currentPath = router.get().currentRoute?.path ?? '/'
const isActive = (path: string) => currentPath === path
const handleLogout = () => {
logout()
router.push('/')
}
return html`
<header class="header">
<div class="container">
<nav class="nav">
<div class="nav-brand">
<${Link} to="/" router=${router}>My SPA<//>
</div>
<ul class="nav-links">
<li class=${isActive('/') ? 'active' : ''}>
<${Link} to="/" router=${router}>Home<//>
</li>
<li class=${isActive('/products') ? 'active' : ''}>
<${Link} to="/products" router=${router}>Products<//>
</li>
${auth.isAuthenticated ? html`
<li class=${isActive('/dashboard') ? 'active' : ''}>
<${Link} to="/dashboard" router=${router}>Dashboard<//>
</li>
<li>
<button class="btn-link" onClick=${handleLogout}>Logout</button>
</li>
<li class="user-info">${auth.user?.name}</li>
` : html`
<li class=${isActive('/login') ? 'active' : ''}>
<${Link} to="/login" router=${router}>Login<//>
</li>
`}
</ul>
</nav>
</div>
</header>
`
}
src/pages/Home.ts
import { html } from '@_bashell/slash'
import { Link } from '@_bashell/slash/router'
import { router } from '../router'
import { authState } from '../services/auth'
export const Home = () => {
const auth = authState.get()
return html`
<div class="home">
<section class="hero">
<h1>Welcome to My SPA</h1>
<p>A single page application built with Slash</p>
${auth.isAuthenticated ? html`
<p class="welcome">Hello, ${auth.user?.name}!</p>
<${Link} to="/dashboard" router=${router} class="btn btn-primary">Go to Dashboard<//>
` : html`
<${Link} to="/login" router=${router} class="btn btn-primary">Get Started<//>
`}
</section>
<section class="cta">
<h2>Browse Our Products</h2>
<${Link} to="/products" router=${router} class="btn btn-secondary">View Products<//>
</section>
</div>
`
}

A rota passa o filtro da query como prop. Clicar num filtro navega para a mesma rota com outra query, e o roteador re-renderiza a página:

src/pages/Products.ts
import { html } from '@_bashell/slash'
import { Link } from '@_bashell/slash/router'
import { router } from '../router'
import type { Product } from '../types'
// Mock de produtos
export const mockProducts: Product[] = [
{ id: '1', name: 'Laptop Pro', description: 'High-performance laptop', price: 1299, category: 'electronics', image: '/images/laptop.jpg' },
{ id: '2', name: 'Wireless Mouse', description: 'Ergonomic wireless mouse', price: 29, category: 'accessories', image: '/images/mouse.jpg' },
{ id: '3', name: 'Mechanical Keyboard', description: 'RGB mechanical keyboard', price: 149, category: 'accessories', image: '/images/keyboard.jpg' }
]
const categories = ['all', 'electronics', 'accessories']
export const Products = ({ category }: { category: string }) => {
const filteredProducts = mockProducts.filter(p => category === 'all' || p.category === category)
const handleFilterChange = (next: string) => {
router.push(next === 'all' ? '/products' : `/products?category=${next}`)
}
return html`
<div class="products-page">
<h1>Our Products</h1>
<div class="filter-buttons">
${categories.map(cat => html`
<button
class=${`filter-btn ${category === cat ? 'active' : ''}`}
onClick=${() => handleFilterChange(cat)}
>
${cat.charAt(0).toUpperCase() + cat.slice(1)}
</button>
`)}
</div>
<div class="products-grid">
${filteredProducts.map(product => html`
<div class="product-card">
<img src=${product.image} alt=${product.name} />
<h3>${product.name}</h3>
<p>${product.description}</p>
<div class="product-footer">
<span class="price">$${product.price}</span>
<${Link} to=${`/products/${product.id}`} router=${router} class="btn btn-sm">View Details<//>
</div>
</div>
`)}
</div>
${filteredProducts.length === 0 ? html`<p>No products found in this category.</p>` : null}
</div>
`
}

Detalhe de produto com route param (state.params.id chega como prop id):

src/pages/ProductDetail.ts
import { html } from '@_bashell/slash'
import { Link } from '@_bashell/slash/router'
import { router } from '../router'
import { mockProducts } from './Products'
export const ProductDetail = ({ id }: { id: string }) => {
const product = mockProducts.find(p => p.id === id)
if (!product) {
return html`
<div class="error">
<h2>Product not found</h2>
<${Link} to="/products" router=${router}>← Back to products<//>
</div>
`
}
return html`
<div class="product-detail">
<${Link} to="/products" router=${router} class="back-link">← Back to products<//>
<div class="product-content">
<img src=${product.image} alt=${product.name} />
<div class="product-info">
<h1>${product.name}</h1>
<div class="product-category">Category: ${product.category}</div>
<p class="product-description">${product.description}</p>
<div class="product-price">$${product.price}</div>
<button class="btn btn-primary" onClick=${() => alert(`Added ${product.name} to cart!`)}>
Add to Cart
</button>
</div>
</div>
</div>
`
}

O formulário guarda o que o usuário digita fora de qualquer state lido no render (os <input> não são recriados a cada tecla). Só LoginStatus lê o state do envio:

src/pages/Login.ts
import { html, createState } from '@_bashell/slash'
import { router } from '../router'
import { login } from '../services/auth'
const status = createState({ isLoading: false, error: '' })
const draft = { email: '', password: '' }
const LoginStatus = () => {
const { isLoading, error } = status.get()
return html`
<div>
${error ? html`<div class="alert alert-error">${error}</div>` : null}
<button type="submit" class="btn btn-primary btn-block" disabled=${isLoading}>
${isLoading ? 'Logging in...' : 'Login'}
</button>
</div>
`
}
export const Login = ({ redirect }: { redirect: string }) => {
const handleSubmit = async (e: Event) => {
e.preventDefault()
status.set({ isLoading: true, error: '' })
try {
await login(draft.email, draft.password)
// Vai para onde o usuário queria ir; replace evita voltar para o login
await router.replace(redirect)
} catch (error: any) {
status.set({ isLoading: false, error: error.message || 'Login failed' })
}
}
return html`
<div class="login-page">
<div class="login-card">
<h1>Login</h1>
<form onSubmit=${handleSubmit}>
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
type="email"
required
onInput=${(e: Event) => { draft.email = (e.target as HTMLInputElement).value }}
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
type="password"
required
onInput=${(e: Event) => { draft.password = (e.target as HTMLInputElement).value }}
/>
</div>
<${LoginStatus} />
</form>
<p class="hint">Use any email and password to login</p>
</div>
</div>
`
}

A proteção está no guard da rota. A página só lê authState:

src/pages/Dashboard.ts
import { html } from '@_bashell/slash'
import { authState } from '../services/auth'
export const Dashboard = () => {
const auth = authState.get()
return html`
<div class="dashboard">
<h1>Dashboard</h1>
<div class="welcome">
<h2>Welcome back, ${auth.user?.name}!</h2>
<p>Email: ${auth.user?.email}</p>
<p>Role: ${auth.user?.role}</p>
</div>
</div>
`
}
src/pages/NotFound.ts
import { html } from '@_bashell/slash'
import { Link } from '@_bashell/slash/router'
import { router } from '../router'
export const NotFound = () => html`
<div class="not-found">
<h1>404</h1>
<p>Page not found</p>
<${Link} to="/" router=${router}>Go home<//>
</div>
`

O Router é usado como componente. O Header também é montado como componente (<${Header} />) para ter a própria reatividade:

src/components/Layout.ts
import { html } from '@_bashell/slash'
import { Router } from '@_bashell/slash/router'
import { Header } from './Header'
import { router } from '../router'
export const Layout = () => html`
<div class="app">
<${Header} />
<main class="main">
<div class="container">
<${Router} router=${router} />
</div>
</main>
<footer class="footer">
<div class="container">
<p>&copy; 2026 My SPA. Built with Slash.</p>
</div>
</footer>
</div>
`
src/index.ts
import { html, render } from '@_bashell/slash'
import { Layout } from './components/Layout'
import './styles.css'
// O router é criado quando './router' é importado (via Layout) e já começa a
// navegação inicial pela URL atual (window.location). O estado de autenticação
// é carregado na criação de `authState`, antes de qualquer guard rodar.
const root = document.getElementById('app')
if (root) {
render(html`<${Layout} />`, root)
}
import { router } from './router'
// Navegação simples
await router.push('/products')
// Com params
await router.push('/products/123')
// Com query strings (na própria string)
await router.push('/products?category=electronics&page=1')
// Voltar / avançar
router.back()
router.forward()
// Ir para índice específico no histórico
router.go(-2) // volta 2 páginas
// Replace (não adiciona ao histórico)
await router.replace('/login')
// Rota atual
router.currentRoute() // RouteMatch | null
router.get() // { currentRoute, params, query, meta, isNavigating }
Terminal window
# Desenvolvimento
bun run dev
# Build
bun run build
  1. Breadcrumbs: Navegação hierárquica
  2. Loading States: indicador usando router.get().isNavigating
  3. Error Boundaries: Tratamento de erros por rota
  4. Nested Routes: subrotas com children
  5. Route Meta: Permissões, títulos, analytics
  6. Scroll Restoration: lembrar a posição do scroll (manual, com router.watch)
  1. SPA: Navegação instantânea sem reload
  2. Route Guards: Controle de acesso, redirecionando com uma string
  3. Páginas como componentes: component: () => html`<${Page} />` para reatividade
  4. Query Params: Estado na URL para compartilhamento
  5. Link com router: a prop router é obrigatória