Blog com SSR
Blog com SSR
Section titled “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.
Funcionalidades
Section titled “Funcionalidades”- ✅ 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
Estrutura do Projeto
Section titled “Estrutura do Projeto”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 globaisTipos TypeScript
Section titled “Tipos TypeScript”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}Template compartilhado: view
Section titled “Template compartilhado: view”O mesmo componente roda no servidor (gera SafeHtml) e no cliente (gera DOM). Um módulo escolhe o template certo:
import { html } from '@_bashell/slash/core'import { htmlString } from '@_bashell/slash/ssr'
// No servidor htmlString gera HTML; no navegador html gera nós DOMexport 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.Data Loaders Isomórficos
Section titled “Data Loaders Isomórficos”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):
// No servidor a API precisa de URL absoluta; no navegador o caminho relativo bastaconst SERVER_ORIGIN = 'http://localhost:3000' // ajuste para o seu ambiente
export const apiUrl = (path: string, isServer: boolean): string => isServer ? `${SERVER_ORIGIN}${path}` : pathcreateLoader(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:
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 minutosexport 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 minutosexport 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 })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.
Componente Layout
Section titled “Componente Layout”Layout base compartilhado por todas as páginas:
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>© 2026 My Blog. Built with Slash.</p> </div> </footer> </div>`Componente PostList
Section titled “Componente PostList”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:
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 clienteexport 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>`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 renderconst 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), })}Componente Comments
Section titled “Componente Comments”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:
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 renderconst 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 digitarexport 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>`Componente PostDetail
Section titled “Componente PostDetail”Visualização de um post, também como view pura:
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>`Servidor HTTP
Section titled “Servidor HTTP”O servidor carrega os dados antes de renderizar, renderiza com renderToString e entrega os dados para o cliente em um script JSON, com serializeStateForScript:
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, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''')
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}`)})Cliente (Hidratação)
Section titled “Cliente (Hidratação)”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:
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 servidorconst raw = document.getElementById('__LOADER_DATA__')?.textContent || '{}'hydrateLoaderCache(deserializeLoaderData(raw))
const path = window.location.pathnamelet 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')}Build Configuration
Section titled “Build Configuration”{ "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" }}{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2022", "DOM"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true, "isolatedModules": true, "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]}Executando o Projeto
Section titled “Executando o Projeto”# Desenvolvimentobun run dev
# Build para produçãobun run build
# Executar produçãobun run startOtimizações de Performance
Section titled “Otimizações de Performance”1. Streaming SSR
Section titled “1. Streaming SSR”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>`)2. Cache de Loaders
Section titled “2. Cache de Loaders”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})3. Invalidação Seletiva
Section titled “3. Invalidação Seletiva”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áriosinvalidateLoader('comments')
// Sem argumento limpa todo o cacheinvalidateLoader()SEO Best Practices
Section titled “SEO Best Practices”- Meta Tags Dinâmicas: Sempre defina title e description baseado no conteúdo
- Open Graph: Adicione meta tags OG para compartilhamento social
- Structured Data: Use JSON-LD para rich snippets
- Sitemap: Gere sitemap.xml automaticamente
- Canonical URLs: Previna conteúdo duplicado
Pontos-Chave de Aprendizado
Section titled “Pontos-Chave de Aprendizado”- SSR para SEO: Conteúdo renderizado no servidor é indexável por crawlers
- Hidratação: o cliente limpa o HTML do servidor e renderiza de novo com os mesmos dados (o HTML do servidor não é reaproveitado)
- Loaders Isomórficos: Mesmo código funciona em servidor e cliente
- Cache Compartilhado:
hydrateLoaderCacheevita fetches desnecessários no cliente - Segurança: o Slash escapa toda string dos componentes e o shell usa
escapeHtml; o JSON da página sai deserializeStateForScript
Próximos Passos
Section titled “Próximos Passos”- SPA com Roteamento - Navegação client-side
- Data Fetching Patterns - Padrões avançados
- Server-Side Rendering - Documentação detalhada de SSR