Skip to content

Server-Side Rendering (SSR)

O Slash oferece suporte completo a Server-Side Rendering (SSR), permitindo renderizar suas aplicações no servidor para melhorar a performance inicial, SEO e experiência do usuário.

SSR (Server-Side Rendering) é o processo de renderizar sua aplicação no servidor, gerando HTML completo que é enviado ao navegador. Isso oferece vários benefícios:

  • Performance: Conteúdo visível mais rápido (FCP - First Contentful Paint)
  • SEO: Crawlers veem o conteúdo completo imediatamente
  • Acessibilidade: Funciona mesmo com JavaScript desabilitado
  • Experiência do Usuário: Reduz o tempo de carregamento percebido

A função renderToString() renderiza seu componente para uma string HTML de forma síncrona.

function renderToString(view: Child | (() => Child)): {
html: string;
state: Record<string, unknown>;
}
  • view: Componente ou função que retorna um componente a ser renderizado

Retorna um objeto contendo:

  • html: String HTML renderizada
  • state: Objeto com os estados reativos serializados (para hidratação no cliente)
import { renderToString, htmlString } from '@ezbug/slash'
const App = () => htmlString`
<div class="app">
<h1>Hello, SSR!</h1>
<p>Renderizado no servidor</p>
</div>
`
const { html, state } = renderToString(App)
console.log(html)
// <div class="app">
// <h1>Hello, SSR!</h1>
// <p>Renderizado no servidor</p>
// </div>
console.log(state)
// {} (sem estados reativos neste exemplo)
import { renderToString, htmlString, createState } from '@ezbug/slash'
const Counter = () => {
const count = createState({ value: 0 })
const { value } = count.get()
return htmlString`
<div>
<h2>Contador: ${value}</h2>
<p>Renderizado com valor inicial</p>
</div>
`
}
const { html, state } = renderToString(Counter)
console.log(html)
// <div>
// <h2>Contador: <!--reactive-start:s0-->0<!--reactive-end:s0--></h2>
// <p>Renderizado com valor inicial</p>
// </div>
console.log(state)
// { s0: 0 }

Note os marcadores de reatividade (<!--reactive-start:s0--> e <!--reactive-end:s0-->). Eles são usados durante a hidratação no cliente para reconectar os estados reativos.

O htmlString é uma versão especial do template tag html otimizada para SSR. Ele renderiza diretamente para strings HTML.

html (Cliente)htmlString (Servidor)
Retorna Node (DOM)Retorna string (HTML)
Usa no navegadorUsa no servidor
Cria elementos reaisCria strings HTML
// ❌ ERRADO: Usar html no servidor
import { html } from '@ezbug/slash'
const App = () => html`<div>Não funciona no servidor</div>`
// ✅ CORRETO: Usar htmlString no servidor
import { htmlString } from '@ezbug/slash'
const App = () => htmlString`<div>Funciona no servidor</div>`

O htmlString escapa automaticamente valores interpolados para prevenir XSS:

const malicious = '<script>alert("xss")</script>'
const userInput = "João <script>alert()</script>"
const App = () => htmlString`
<div>
<p>${userInput}</p>
</div>
`
const { html } = renderToString(App)
// <div>
// <p>João &lt;script&gt;alert()&lt;/script&gt;</p>
// </div>

Para aplicações maiores, renderToStream() oferece streaming SSR, enviando HTML em chunks incrementais.

async function* renderToStream(
view: Child | (() => Child)
): AsyncGenerator<string, void, unknown>
  1. TTFB Melhorado: Primeiro byte chega mais rápido
  2. Renderização Progressiva: Navegador começa a renderizar antes do HTML completo
  3. Melhor Performance: Chunks de 16KB otimizados
  4. Menor Memory Usage: Processa em partes
import { renderToStream, htmlString } from '@ezbug/slash'
const App = () => htmlString`
<!DOCTYPE html>
<html>
<head>
<title>Streaming SSR</title>
</head>
<body>
<div id="app">
<h1>Conteúdo grande...</h1>
${Array.from({ length: 100 }).map((_, i) =>
htmlString`<p>Parágrafo ${i}</p>`
)}
</div>
</body>
</html>
`
// Servidor Bun
Bun.serve({
port: 3000,
async fetch(req) {
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of renderToStream(App)) {
controller.enqueue(new TextEncoder().encode(chunk))
}
controller.close()
}
})
return new Response(stream, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
})
}
})
import { renderToStream, htmlString } from '@ezbug/slash'
import { createServer } from 'http'
const App = () => htmlString`
<div>
<h1>Streaming SSR com Node.js</h1>
</div>
`
createServer(async (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
for await (const chunk of renderToStream(App)) {
res.write(chunk)
}
res.end()
}).listen(3000)

O renderToStream() automaticamente inclui um script com o estado serializado no final:

<div>...</div>
<script id="__SLASH_STATE__" type="application/json">
{"s0":42,"s1":"active"}
</script>

Quando você usa estados reativos em atributos, o Slash adiciona marcadores data-reactive-* para hidratação:

const isActive = createState({ value: true })
const Button = () => {
const { value } = isActive.get()
return htmlString`<button class=${value ? 'active' : ''}>Click</button>`
}
const { html } = renderToString(Button)
// <button class="active" data-reactive-class="s0">Click</button>
AtributoMarcadorDescrição
classdata-reactive-classClasses dinâmicas
valuedata-reactive-valueValor de inputs
checkeddata-reactive-checkedCheckbox/radio checked
Outrosdata-reactive-*Atributos customizados

Event handlers (onClick, onInput, etc.) são ignorados durante SSR, pois só funcionam no cliente:

const Button = () => htmlString`
<button onClick=${() => alert('click')}>
Click me
</button>
`
const { html } = renderToString(Button)
// <button>Click me</button>
// (onClick foi removido)

Os event handlers serão automaticamente reconectados durante a hidratação no cliente.

O Slash trata corretamente void elements (elementos auto-fechados):

const Form = () => htmlString`
<form>
<input type="text" />
<br />
<img src="logo.png" />
</form>
`
const { html } = renderToString(Form)
// <form>
// <input type="text">
// <br>
// <img src="logo.png">
// </form>

Lista de void elements: area, base, br, col, embed, hr, img, input, link, meta, param, source, track, wbr

type Todo = { id: number; text: string; done: boolean }
const todos = createState<Todo[]>({
value: [
{ id: 1, text: 'Aprender SSR', done: true },
{ id: 2, text: 'Fazer hydration', done: false }
]
})
const TodoList = () => {
const { value: items } = todos.get()
return htmlString`
<ul class="todos">
${items.map(todo => htmlString`
<li class=${todo.done ? 'done' : ''}>
<input type="checkbox" checked=${todo.done} />
<span>${todo.text}</span>
</li>
`)}
</ul>
`
}
const { html, state } = renderToString(TodoList)
const Header = ({ title }: { title: string }) => htmlString`
<header>
<h1>${title}</h1>
</header>
`
const Footer = () => htmlString`
<footer>
<p>&copy; 2026 My App</p>
</footer>
`
const Layout = ({ children }: { children: string }) => htmlString`
<div class="layout">
<${Header} title="Minha App" />
<main>${children}</main>
<${Footer} />
</div>
`
const Page = () => Layout({
children: htmlString`<p>Conteúdo da página</p>`
})
const { html } = renderToString(Page)

O Slash suporta múltiplos formatos para classes:

const className = 'btn btn-primary'
const Button = () => htmlString`<button class=${className}>Click</button>`
const classes = ['btn', 'btn-primary', 'active']
const Button = () => htmlString`<button class=${classes}>Click</button>`
// <button class="btn btn-primary active">Click</button>
const classes = {
btn: true,
'btn-primary': true,
active: false
}
const Button = () => htmlString`<button class=${classes}>Click</button>`
// <button class="btn btn-primary">Click</button>

Estilos inline podem ser objetos:

const styles = {
color: 'red',
fontSize: '16px',
backgroundColor: '#f0f0f0'
}
const Box = () => htmlString`<div style=${styles}>Styled box</div>`
const { html } = renderToString(Box)
// <div style="color: red; font-size: 16px; background-color: #f0f0f0">Styled box</div>

Embora batch() seja mais útil no cliente, você pode usá-lo no servidor para agrupar operações:

import { batch, createState, renderToString, htmlString } from '@ezbug/slash'
const data = createState({ count: 0, name: '' })
batch(() => {
data.set({ count: 10, name: 'John' })
})
const App = () => {
const { count, name } = data.get()
return htmlString`<div>${name}: ${count}</div>`
}
const { html } = renderToString(App)

O renderToStream() divide o HTML em chunks de 16KB, otimizados para a maioria dos cenários:

// HTML grande é dividido automaticamente
const largeContent = 'x'.repeat(50000)
const App = () => htmlString`<div>${largeContent}</div>`
for await (const chunk of renderToStream(App)) {
console.log(`Chunk size: ${chunk.length} bytes`)
// Chunk size: 16384 bytes
// Chunk size: 16384 bytes
// ...
}
import { Hono } from 'hono'
import { renderToString, htmlString } from '@ezbug/slash'
const app = new Hono()
app.get('/', (c) => {
const App = () => htmlString`
<!DOCTYPE html>
<html>
<body>
<div id="app">
<h1>Hello from Hono + Slash SSR!</h1>
</div>
<script src="/client.js"></script>
</body>
</html>
`
const { html, state } = renderToString(App)
return c.html(html + `
<script>
window.__SLASH_STATE__ = ${JSON.stringify(state)}
</script>
`)
})
export default app
import express from 'express'
import { renderToString, htmlString } from '@ezbug/slash'
const app = express()
app.get('/', (req, res) => {
const App = () => htmlString`
<div>
<h1>Hello from Express + Slash SSR!</h1>
</div>
`
const { html, state } = renderToString(App)
res.send(`
<!DOCTYPE html>
<html>
<body>
${html}
<script>
window.__SLASH_STATE__ = ${JSON.stringify(state)}
</script>
<script src="/client.js"></script>
</body>
</html>
`)
})
app.listen(3000)
  • ✅ Use htmlString (não html) nos componentes do servidor
  • ✅ Use renderToString() para SSR síncrono
  • ✅ Use renderToStream() para streaming SSR (melhor performance)
  • ✅ Serialize o estado reativo e injete no HTML
  • ✅ Implemente hidratação no cliente (veja Hydration)
  • ✅ Event handlers são ignorados no servidor (reconectados no cliente)
  • ✅ Atributos reativos recebem marcadores data-reactive-*
  • ✅ HTML é escapado automaticamente para prevenir XSS