Building an Interactive Terminal in Next.js
Software engineer and entrepreneur based in San Francisco.
Software engineer and entrepreneur based in San Francisco.
Building an embedded terminal UI with macOS-style window controls in Next.js (App Router). Key challenge: managing shared window state across components/routes without hydration errors using React Context.
Key components and their roles:
app/layout.tsx
) to manage state for all registered window components.useRegisteredWindowState
hook to connect to the global provider, accessing their specific state (windowState
) and control functions (minimize
, maximize
, etc.).useRegisteredWindowState
hook for consistent control.onClose
, onMinimize
, onMaximize
callbacks provided by its parent window component (which obtains them from the registration hook).Managing multiple window states across pages required a global React Context pattern to avoid hydration issues common with local state management.
Component-local state (even with localStorage
) caused server-client UI mismatches (hydration errors) and was complex for multiple windows. Solution: A global registry via React Context.
React.createContext
to define a central store.id
, icon
, title
, state
).windows
state object and management functions (registerWindow
, setWindowState
, etc.).RootLayout
in app/layout.tsx
, making the state available application-wide.useState
to hold and update the central windows
record.useEffect
hook conditionally adds CSS classes (window-maximized
, window-minimized
) to the <body>
tag based on the overall window state. This allows global CSS to adjust shared layout elements (like headers) without complex prop drilling.// Simplified app/layout.tsx structure
import { GlobalWindowRegistryProvider } from '@/lib/context/GlobalWindowRegistryContext';
// ... other imports
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Providers>
<GlobalWindowRegistryProvider>
{/* ... layout elements ... */}
<main>
{/* Registered components like ClientTerminal */}
{children}
</main>
<FloatingRestoreButtons />
</GlobalWindowRegistryProvider>
</Providers>
</body>
</html>
);
}
useEffect
.windowState
, memoized action functions (minimize
, maximize
, close
, restore
), and a crucial isRegistered
boolean flag to ensure rendering only occurs safely after client-side hydration and registration is complete.isRegistered
flag from the hook before rendering state-dependent UI to prevent server/client mismatches. Render default/loading state until isRegistered
is true
.position: fixed
, dimensions, z-index) based on its windowState
.null
. The separate FloatingRestoreButtons
component handles the UI for restoring them.body.window-maximized
/ body.window-minimized
classes sparingly to adjust shared elements (like site headers) without interfering with individual window component layouts.Passing non-serializable data (like JSX elements or functions) directly from Server Components to Client Components causes errors in Next.js.
Data crossing the Server-Client boundary must be JSON-serializable. Complex objects, functions, or modules required on the server (like fs
) cannot be passed directly, leading to client-side errors or broken Hot Module Replacement (HMR).
The reliable pattern involves strict separation:
"use server"
or using import "server-only"
. These handle server-specific tasks (DB access, API calls, file reads) and process data into simple, serializable formats (plain objects/arrays).Implemented using standard React hooks:
useRef
obtains a direct reference to the terminal's scrollable container element.useEffect
monitors changes in the command history (managed in a separate TerminalContext
).scrollTop
to its scrollHeight
, ensuring the latest output is always visible.Command processing logic is centralized for maintainability:
useTerminal
hook takes the submitted command string.handleCommand
function.useRouter
for client-side navigation, or returning error messages.Creating responsive, syntax-highlighted code blocks with proper dark mode support required solving several CSS conflicts.
PrismJS's default themes assume a fixed color scheme. Making syntax highlighting work in both light and dark modes while maintaining consistent UI required custom CSS overrides and DOM structure simplification.
// app/layout.tsx
import './globals.css';
import './code-blocks.css';
import '../components/ui/code-block/prism-syntax-highlighting/prism.css';
CodeBlock
React component provides the macOS-style window UI around the pre/code elements.code-blocks.css
override PrismJS's default theme for dark appearances.One persistent issue was text shadow/highlighting in dark mode. PrismJS's default styling includes text shadows and background colors that conflicted with our dark theme.
The solution required multiple CSS overrides:
/* Force remove backgrounds and text shadows from ALL tokens */
pre[class*="language-"] *,
code[class*="language-"] * {
text-shadow: none !important;
background: transparent !important;
box-shadow: none !important;
}
/* Override PrismJS token styles for dark mode */
.dark .token.comment,
.dark .token.string,
.dark .token.function /* etc... */ {
color: #specifc-color; /* Color-only syntax highlighting */
background: transparent !important;
}
These rules ensure tokens are styled with color only, not backgrounds or shadows.
Another challenge was border radius rendering artifacts. The complex nesting of elements with borders and backgrounds created visual glitches at the rounded corners.
The solution was to simplify the DOM structure:
<div className="rounded-lg overflow-hidden"> {/* Single container with rounded corners */}
{/* Header */}
<div className="rounded-t-lg">
<WindowControls />
</div>
{/* Content */}
<div>
<pre className="...">
{children}
</pre>
</div>
</div>
This flatter structure avoids border conflicts and ensures clean rounded corners.
Using Tailwind's @apply
directive in global CSS caused linting errors. Replace:
/* Problematic */
:not(pre) > code {
@apply bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm font-medium;
}
/* Solution - standard CSS */
:not(pre) > code {
background-color: #f3f4f6; /* bg-gray-100 */
color: #111827; /* text-gray-900 */
padding: 0.125rem 0.375rem; /* px-1.5 py-0.5 */
border-radius: 0.25rem; /* rounded */
/* etc. */
}
.dark :not(pre) > code {
background-color: #1f2937; /* dark:bg-gray-800 */
color: #f9fafb; /* dark:text-gray-50 */
}
useRegisteredWindowState
) combined with an isRegistered
flag is crucial for safely managing client-side state dependent on context, preventing hydration errors.!important
flags judiciously when working with third-party CSS that conflicts with your theme. Prefer explicit selectors over complex nesting that can lead to specificity wars.