Skip to content

Blog com SSR

Este exemplo demonstra como criar um blog completo usando Slash com Server-Side Rendering (SSR). Você aprenderá sobre renderização no servidor, hidratação no cliente, data loading isomórfico e otimização de performance.

  • ✅ Renderização no servidor (SSR) para SEO e performance
  • ✅ Hidratação no cliente para interatividade
  • ✅ Listagem de posts com paginação
  • ✅ Visualização de post individual
  • ✅ Sistema de comentários
  • ✅ Data loading isomórfico
  • ✅ Meta tags dinâmicas para SEO
src/
├── server/
│ ├── index.ts # Servidor HTTP
│ ├── routes.ts # Definição de rotas
│ └── data/
│ ├── posts.ts # API de posts
│ └── comments.ts # API de comentários
├── client/
│ ├── index.ts # Entry point do cliente
│ ├── PostListPage.ts # Lista com states (cliente)
│ └── Comments.ts # Comentários com states (cliente)
├── shared/
│ ├── view.ts # html no cliente, htmlString no servidor
│ ├── api.ts # URL base da API (absoluta no servidor)
│ ├── components/
│ │ ├── Layout.ts # Layout base
│ │ ├── PostList.ts # View pura da lista de posts
│ │ └── PostDetail.ts # View pura do detalhe do post
│ ├── loaders/
│ │ ├── posts.ts # Loader de posts
│ │ └── comments.ts # Loader de comentários
│ └── types.ts # Tipos compartilhados
└── public/
└── styles.css # Estilos globais
src/shared/types.ts
export interface Post {
id: string
slug: string
title: string
excerpt: string
content: string
author: {
name: string
avatar: string
}
publishedAt: string
tags: string[]
readTime: number
}
export interface Comment {
id: string
postId: string
author: string
content: string
createdAt: string
}
export interface PostListResponse {
posts: Post[]
total: number
page: number
pageSize: number
hasMore: boolean
}

O mesmo componente roda no servidor (gera SafeHtml) e no cliente (gera DOM). Um módulo escolhe o template certo:

src/shared/view.ts
import { html } from '@_bashell/slash/core'
import { htmlString } from '@_bashell/slash/ssr'
// No servidor htmlString gera HTML; no navegador html gera nós DOM
export const view = (typeof document === 'undefined' ? htmlString : html) as typeof html
// Strings são sempre escapadas, nos dois lados: título, resumo, tags, nome do autor
// e conteúdo podem ser interpolados direto, sem helper.

No navegador, fetch('/api/...') com caminho relativo funciona. No servidor Node não existe origem base, então um caminho relativo falha. Os loaders usam ctx.isServer para escolher uma URL absoluta no servidor (se preferir, chame direto a camada de dados em vez de passar por HTTP):

src/shared/api.ts
// No servidor a API precisa de URL absoluta; no navegador o caminho relativo basta
const SERVER_ORIGIN = 'http://localhost:3000' // ajuste para o seu ambiente
export const apiUrl = (path: string, isServer: boolean): string =>
isServer ? `${SERVER_ORIGIN}${path}` : path

createLoader(fn, { key, ttl }) recebe uma função que lê { params } e devolve uma função assíncrona. No servidor ela sempre executa; no cliente o resultado fica em cache por key + params:

src/shared/loaders/posts.ts
import { createLoader } from '@_bashell/slash/ssr'
import { apiUrl } from '../api'
import type { Post, PostListResponse } from '../types'
// Loader para lista de posts com cache de 5 minutos
export const postsLoader = createLoader<PostListResponse>(
async ({ params, isServer }) => {
const response = await fetch(
apiUrl(`/api/posts?page=${params.page ?? '1'}&pageSize=${params.pageSize ?? '10'}`, isServer)
)
if (!response.ok) throw new Error('Failed to load posts')
return response.json()
},
{ key: 'posts', ttl: 5 * 60 * 1000 }
)
// Loader para post individual com cache de 10 minutos
export const postLoader = createLoader<Post>(
async ({ params, isServer }) => {
const response = await fetch(apiUrl(`/api/posts/${params.slug}`, isServer))
if (!response.ok) throw new Error('Post not found')
return response.json()
},
{ key: 'post', ttl: 10 * 60 * 1000 }
)
src/shared/loaders/comments.ts
import { createLoader } from '@_bashell/slash/ssr'
import { apiUrl } from '../api'
import type { Comment } from '../types'
export const commentsLoader = createLoader<Comment[]>(
async ({ params, isServer }) => {
const response = await fetch(apiUrl(`/api/posts/${params.postId}/comments`, isServer))
if (!response.ok) throw new Error('Failed to load comments')
return response.json()
},
{ key: 'comments', ttl: 2 * 60 * 1000 } // 2 minutos
)

Um loader é uma função assíncrona: chame-o com await loader({ params, isServer }), no servidor antes de renderizar, e no cliente antes de montar a interface.

Layout base compartilhado por todas as páginas:

src/shared/components/Layout.ts
import { view } from '../view'
interface LayoutProps {
children: unknown
}
export const Layout = ({ children }: LayoutProps) => view`
<div class="layout">
<header class="header">
<div class="container">
<h1 class="logo"><a href="/">My Blog</a></h1>
<nav class="nav">
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
</div>
</header>
<main class="main">
<div class="container">${children as any}</div>
</main>
<footer class="footer">
<div class="container">
<p>&copy; 2026 My Blog. Built with Slash.</p>
</div>
</footer>
</div>
`

A view é pura (recebe dados por props), então serve ao servidor e ao cliente. A versão do cliente guarda a página e os dados em states fora do componente e os lê dentro do componente:

src/shared/components/PostList.ts
import { view } from '../view'
import type { PostListResponse } from '../types'
const formatDate = (date: string): string =>
new Date(date).toLocaleDateString('pt-BR', { year: 'numeric', month: 'long', day: 'numeric' })
interface PostListViewProps {
data: PostListResponse
onPrev?: () => void
onNext?: () => void
}
// View pura: usada no servidor (sem handlers) e no cliente
export const PostListView = ({ data, onPrev, onNext }: PostListViewProps) => view`
<div class="post-list">
<h1>Latest Posts</h1>
<div class="posts">
${data.posts.map((post) => view`
<article class="post-card">
<h2><a href=${`/posts/${post.slug}`}>${post.title}</a></h2>
<div class="post-meta">
<span class="author-name">${post.author.name}</span>
<time datetime=${post.publishedAt}>${formatDate(post.publishedAt)}</time>
<span>${post.readTime} min read</span>
</div>
<p class="post-excerpt">${post.excerpt}</p>
<div class="post-tags">
${post.tags.map((tag) => view`<span class="tag">${tag}</span>`)}
</div>
</article>
`)}
</div>
<div class="pagination">
<button class="btn" onClick=${onPrev} disabled=${data.page === 1}>Previous</button>
<span class="page-info">
Page ${data.page} of ${Math.ceil(data.total / data.pageSize)}
</span>
<button class="btn" onClick=${onNext} disabled=${!data.hasMore}>Next</button>
</div>
</div>
`
src/client/PostListPage.ts
import { html, createState } from '@_bashell/slash/core'
import { postsLoader } from '../shared/loaders/posts'
import { PostListView } from '../shared/components/PostList'
import type { PostListResponse } from '../shared/types'
// States fora do componente: dentro dele seriam recriados a cada render
const listData = createState<PostListResponse | null>(null)
export const loadPage = async (page: number) => {
const data = await postsLoader({ params: { page: String(page) }, isServer: false })
listData.set(data)
}
export const PostListPage = () => {
// Este get() inscreve o componente: ele re-renderiza quando listData muda
const data = listData.get()
if (!data) return html`<div class="loading"><p>Loading posts...</p></div>`
return PostListView({
data,
onPrev: () => data.page > 1 && loadPage(data.page - 1),
onNext: () => data.hasMore && loadPage(data.page + 1),
})
}

Comentários e formulário são componentes separados. Assim, só a lista re-renderiza quando chega um comentário novo, e os <input> não são recriados enquanto o usuário digita:

src/client/Comments.ts
import { html, createState, batch, invalidateLoader } from '@_bashell/slash'
import { commentsLoader } from '../shared/loaders/comments'
import type { Comment } from '../shared/types'
const comments = createState<Comment[]>([])
const isSubmitting = createState(false)
// Texto em edição fica fora de qualquer state lido no render
const draft = { author: '', content: '' }
const formatDate = (date: string): string =>
new Date(date).toLocaleDateString('pt-BR', {
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
})
export const loadComments = async (postId: string) => {
comments.set(await commentsLoader({ params: { postId }, isServer: false }))
}
const submit = async (postId: string) => {
isSubmitting.set(true)
try {
const response = await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(draft),
})
if (!response.ok) throw new Error('Failed to post comment')
draft.author = ''
draft.content = ''
invalidateLoader('comments') // descarta o cache dos comentários
await loadComments(postId)
} catch (error) {
console.error('Error posting comment:', error)
alert('Failed to post comment. Please try again.')
} finally {
isSubmitting.set(false)
}
}
const CommentList = () => {
const list = comments.get()
return html`
<div class="comments-list">
<h3>Comments (${list.length})</h3>
${list.length === 0
? html`<p class="no-comments">No comments yet. Be the first!</p>`
: list.map((comment) => html`
<div class="comment">
<strong>${comment.author}</strong>
<time datetime=${comment.createdAt}>${formatDate(comment.createdAt)}</time>
<p class="comment-content">${comment.content}</p>
</div>
`)}
</div>
`
}
const SubmitButton = () => html`
<button type="submit" class="btn btn-primary" disabled=${isSubmitting.get()}>
${isSubmitting.get() ? 'Posting...' : 'Post Comment'}
</button>
`
// Não lê nenhum state: os campos não são recriados ao digitar
export const Comments = ({ postId }: { postId: string }) => html`
<section class="comments-section">
<${CommentList} />
<form
class="comment-form"
onSubmit=${(e: Event) => { e.preventDefault(); submit(postId) }}
>
<h4>Leave a Comment</h4>
<input
type="text"
required
placeholder="Name"
onInput=${(e: Event) => { draft.author = (e.target as HTMLInputElement).value }}
/>
<textarea
rows="4"
required
onInput=${(e: Event) => { draft.content = (e.target as HTMLTextAreaElement).value }}
></textarea>
<${SubmitButton} />
</form>
</section>
`

Visualização de um post, também como view pura:

src/shared/components/PostDetail.ts
import { view } from '../view'
import type { Post } from '../types'
const formatDate = (date: string): string =>
new Date(date).toLocaleDateString('pt-BR', { year: 'numeric', month: 'long', day: 'numeric' })
// `comments` é opcional: o servidor não renderiza o formulário, o cliente injeta <Comments />
export const PostDetail = ({ post, comments }: { post: Post; comments?: unknown }) => view`
<article class="post-detail">
<header class="post-header">
<h1>${post.title}</h1>
<div class="post-meta">
<span class="author-name">${post.author.name}</span>
<time datetime=${post.publishedAt}>${formatDate(post.publishedAt)}</time>
<span>${post.readTime} min read</span>
</div>
<div class="post-tags">
${post.tags.map((tag) => view`<span class="tag">${tag}</span>`)}
</div>
</header>
<div class="post-content">
<p>${post.content}</p>
</div>
<footer class="post-footer">
<a href="/" class="btn">← Back to posts</a>
</footer>
${(comments ?? null) as any}
</article>
`

O servidor carrega os dados antes de renderizar, renderiza com renderToString e entrega os dados para o cliente em um script JSON, com serializeStateForScript:

src/server/index.ts
import http from 'node:http'
import { renderToString, serializeStateForScript } from '@_bashell/slash/ssr'
import { Layout } from '../shared/components/Layout'
import { PostListView } from '../shared/components/PostList'
import { PostDetail } from '../shared/components/PostDetail'
import { postsLoader, postLoader } from '../shared/loaders/posts'
const PORT = process.env.PORT || 3000
// Escapa texto antes de colocá-lo em HTML montado à mão (title, meta...)
const escapeHtml = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
const server = http.createServer(async (req, res) => {
const url = new URL(req.url!, `http://${req.headers.host}`)
// API routes e arquivos estáticos
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/public/')) {
// ... implementação
return
}
try {
let title = 'My Blog'
let body: () => unknown
// Dados dos loaders, com as chaves de cache que o cliente vai usar
const loaderData: Record<string, unknown> = {}
if (url.pathname === '/') {
const params = { page: '1' }
const data = await postsLoader({ params, isServer: true })
loaderData[`posts:${JSON.stringify(params)}`] = data
title = 'Latest Posts - My Blog'
body = () => PostListView({ data })
} else if (url.pathname.startsWith('/posts/')) {
const params = { slug: url.pathname.split('/')[2]! }
const post = await postLoader({ params, isServer: true })
loaderData[`post:${JSON.stringify(params)}`] = post
title = `${post.title} - My Blog`
body = () => PostDetail({ post })
} else {
res.writeHead(404, { 'Content-Type': 'text/html' })
res.end('<h1>404 Not Found</h1>')
return
}
// renderToString devolve { html, state }
const { html: markup } = renderToString(() => Layout({ children: body() }))
// O documento é um template literal comum: htmlString com <!DOCTYPE> não funciona
// e, dentro dele, o JSON seria escapado como texto. O JSON usa serializeStateForScript.
const page = `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(title)}</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<div id="app">${markup}</div>
<script id="__LOADER_DATA__" type="application/json">${serializeStateForScript(loaderData)}</script>
<script type="module" src="/public/client.js"></script>
</body>
</html>`
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(page)
} catch (error) {
console.error('SSR Error:', error)
res.writeHead(500, { 'Content-Type': 'text/html' })
res.end('<h1>500 Internal Server Error</h1>')
}
})
server.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`)
})

O cliente reabastece o cache dos loaders com os dados do servidor e então monta a interface. A hidratação do Slash limpa o container e renderiza do zero (veja Hydration); como os dados já estão no cache, a primeira renderização usa os mesmos dados do servidor:

src/client/index.ts
import { html, render } from '@_bashell/slash/core'
import { hydrateLoaderCache, deserializeLoaderData } from '@_bashell/slash/ssr'
import { Layout } from '../shared/components/Layout'
import { PostDetail } from '../shared/components/PostDetail'
import { postLoader } from '../shared/loaders/posts'
import { PostListPage, loadPage } from './PostListPage'
import { Comments, loadComments } from './Comments'
// Restaura o cache dos loaders com os dados enviados pelo servidor
const raw = document.getElementById('__LOADER_DATA__')?.textContent || '{}'
hydrateLoaderCache(deserializeLoaderData(raw))
const path = window.location.pathname
let content: unknown
if (path === '/') {
await loadPage(1) // vem do cache hidratado
content = html`<${PostListPage} />`
} else if (path.startsWith('/posts/')) {
const slug = path.split('/')[2]!
const post = await postLoader({ params: { slug }, isServer: false })
await loadComments(post.id)
content = PostDetail({ post, comments: html`<${Comments} postId=${post.id} />` })
}
if (content) {
// render() limpa o HTML do servidor no container e renderiza no cliente
render(Layout({ children: content }), '#app')
}
{
"name": "slash-blog-ssr",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "NODE_ENV=development tsx watch src/server/index.ts",
"build": "bun run build:client && bun run build:server",
"build:client": "esbuild src/client/index.ts --bundle --outfile=dist/public/client.js --format=esm",
"build:server": "esbuild src/server/index.ts --bundle --outfile=dist/server.js --platform=node --format=esm",
"start": "NODE_ENV=production node dist/server.js"
},
"dependencies": {
"@_bashell/slash": "latest"
},
"devDependencies": {
"@types/node": "^20.0.0",
"esbuild": "^0.19.0",
"tsx": "^4.0.0"
}
}
Terminal window
# Desenvolvimento
bun run dev
# Build para produção
bun run build
# Executar produção
bun run start

renderToStream envia a resposta em chunks, mas renderiza a árvore inteira antes do primeiro chunk: ele não melhora o tempo até o primeiro byte. Use-o apenas se quiser escrever a resposta em partes. O documento (head e tail) é texto comum, e o script __SLASH_STATE__ vem no fim do stream:

import { renderToStream, serializeStateForScript } from '@_bashell/slash/ssr'
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.write(`<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8" /><title>${escapeHtml(title)}</title></head><body><div id="app">`)
for await (const chunk of renderToStream(() => Layout({ children: body() }))) {
res.write(chunk)
}
res.end(`</div>
<script id="__LOADER_DATA__" type="application/json">${serializeStateForScript(loaderData)}</script>
<script type="module" src="/public/client.js"></script></body></html>`)

Os loaders já têm cache embutido. Configure TTLs apropriados:

createLoader(fetchFn, {
key: 'posts',
ttl: 10 * 60 * 1000, // 10 minutos para conteúdo estável
// ou
// ttl: 30 * 1000, // 30 segundos para conteúdo dinâmico
})

Invalide apenas os loaders necessários:

import { invalidateLoader } from '@_bashell/slash'
// Após criar novo post: limpa as entradas do loader com key 'posts'
invalidateLoader('posts')
// Após novo comentário: limpa as entradas do loader de comentários
invalidateLoader('comments')
// Sem argumento limpa todo o cache
invalidateLoader()
  1. Meta Tags Dinâmicas: Sempre defina title e description baseado no conteúdo
  2. Open Graph: Adicione meta tags OG para compartilhamento social
  3. Structured Data: Use JSON-LD para rich snippets
  4. Sitemap: Gere sitemap.xml automaticamente
  5. Canonical URLs: Previna conteúdo duplicado
  1. SSR para SEO: Conteúdo renderizado no servidor é indexável por crawlers
  2. Hidratação: o cliente limpa o HTML do servidor e renderiza de novo com os mesmos dados (o HTML do servidor não é reaproveitado)
  3. Loaders Isomórficos: Mesmo código funciona em servidor e cliente
  4. Cache Compartilhado: hydrateLoaderCache evita fetches desnecessários no cliente
  5. Segurança: o Slash escapa toda string dos componentes e o shell usa escapeHtml; o JSON da página sai de serializeStateForScript