Ink Web
Guides

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

components/my-terminal.tsx
'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>
  )
}
components/terminal-wrapper.tsx
'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 />
}
app/page.tsx
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:

  1. Server render: Renders a skeleton with the correct height
  2. Client hydration: Keeps the skeleton visible (no animation reset)
  3. Terminal loads: The actual terminal component loads in the background
  4. 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 rows

The 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.