SSR & Next.js
How to use Ink Web with server-side rendering frameworks
Ink Web provides first-class support for Next.js and other SSR frameworks. Use the createDynamicTerminal helper for the best experience.
Quick Start
'use client'
import { InkTerminalBox, Box, Text } from 'ink-web'
import 'ink-web/css'
import 'xterm/css/xterm.css'
export function MyTerminal({ onReady }: { onReady?: () => void }) {
return (
<InkTerminalBox rows={15} focus onReady={onReady}>
<Box flexDirection="column">
<Text color="green">Hello from Ink!</Text>
</Box>
</InkTerminalBox>
)
}'use client'
import { createDynamicTerminal } from 'ink-web/next'
const Terminal = createDynamicTerminal(
() => import('./my-terminal').then(m => m.MyTerminal),
{ rows: 15 }
)
export function TerminalWrapper() {
return <Terminal />
}import { TerminalWrapper } from '@/components/terminal-wrapper'
export default function Page() {
return (
<div className="p-8">
<h1>My App</h1>
<TerminalWrapper />
</div>
)
}That's it! The helper automatically handles:
- Dynamic imports with
ssr: false - Loading states during initialization
- Smooth transitions without layout shifts
- Preventing animation resets during hydration
How It Works
The createDynamicTerminal helper creates a wrapper that:
- Server render: Renders a skeleton with the correct height
- Client hydration: Keeps the skeleton visible (no animation reset)
- Terminal loads: The actual terminal component loads in the background
- Terminal ready: Swaps to the real terminal when xterm.js is initialized
This prevents the common SSR issues:
- "self is not defined" errors
- Layout shifts and white flashes
- Loading spinner resets on hydration
Important: The onReady Prop
Your terminal component must accept and pass through the onReady prop:
export function MyTerminal({ onReady }: { onReady?: () => void }) {
return (
<InkTerminalBox rows={15} onReady={onReady}>
{/* ... */}
</InkTerminalBox>
)
}This tells the wrapper when to swap from the skeleton to the real terminal.
Customizing Loading States
See the Loading States guide for information on:
- Different loading types (spinner, skeleton, progress)
- Positioning options
- Custom loading components
The getTerminalHeight Utility
For manual setups, use the SSR-safe height calculator:
import { getTerminalHeight } from 'ink-web/utils'
const height = getTerminalHeight(15) // Returns pixel height for 15 rowsThe formula is: rows * 18 + 20 (line height + padding).
Important: Import from ink-web/utils, not ink-web:
// GOOD - SSR-safe
import { getTerminalHeight } from 'ink-web/utils'
// BAD - causes "self is not defined" error
import { getTerminalHeight } from 'ink-web'Manual Setup
If you need more control than createDynamicTerminal provides, you can set things up manually:
'use client'
import dynamic from 'next/dynamic'
import { InkTerminalLoadingPlaceholder } from 'ink-web/utils'
import { useState } from 'react'
const MyTerminal = dynamic(
() => import('./my-terminal').then(m => m.MyTerminal),
{ ssr: false }
)
export function TerminalWrapper() {
const [ready, setReady] = useState(false)
return (
<div style={{ position: 'relative' }}>
{!ready && <InkTerminalLoadingPlaceholder rows={15} />}
<div style={{
visibility: ready ? 'visible' : 'hidden',
position: ready ? 'relative' : 'absolute',
top: 0, left: 0, right: 0
}}>
<MyTerminal onReady={() => setReady(true)} />
</div>
</div>
)
}This approach keeps the skeleton DOM element alive through hydration, preventing animation resets.
Common Issues
"self is not defined"
This happens when importing browser-only code on the server. Always use dynamic imports:
// BAD
import { MyTerminal } from './my-terminal'
// GOOD
const MyTerminal = dynamic(() => import('./my-terminal'), { ssr: false })Layout shift on load
Make sure rows matches between your skeleton and terminal:
// Both must use the same rows value
createDynamicTerminal(importFn, { rows: 15 })
// In your terminal component
<InkTerminalBox rows={15} ... />Loading animation resets
Use createDynamicTerminal instead of the standard dynamic() with a loading option. The helper keeps the skeleton element alive through hydration to prevent animation resets.