Building an Interactive Terminal in Next.js
Software engineer and founder with a background in finance and tech. Currently building aVenture.vc, a platform for researching private companies. Based in San Francisco.
Overview: Goal & Key Challenges
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.
1: Component Architecture
Key components and their roles:
1.1: Global State Provider
- Role: Central React Context placed in the root layout (
app/layout.tsx) to manage state for all registered window components.
1.2: Feature Client Components
- Role: Represent features/pages (e.g., Blog) designed to behave like independent,
controllable windows. - Integration: Use a custom
useRegisteredWindowStatehook to connect to the global provider, accessing their specific state (windowState) and control functions (minimize,maximize, etc.).
1.3: Floating Restore Buttons
- Role: Reads the global state to display buttons allowing users to restore minimized or closed windows.
1.4: Terminal Component
- Role: The interactive terminal UI itself, also registered as a window via the
useRegisteredWindowStatehook for consistent control.
1.5: Window Control Buttons
- Role: Provides standard macOS-style UI buttons (close/minimize/maximize). - Integration:
A simple UI component accepting
onClose,onMinimize,onMaximizecallbacks provided by its parent window component (which obtains them from the registration hook).
2: Window State Management
Managing multiple window states across pages required a global React Context pattern to avoid hydration issues common with local state management.
Problem: Local State & Hydration Conflicts
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.
2.1: Global Context Details
- Uses
React.createContextto define a central store. - Holds a record mapping unique window IDs to their information (id,icon,title,state). - Provides access to the sharedwindowsstate object and management functions (registerWindow,setWindowState, etc.).
2.2: Provider Implementation
- The Context Provider component wraps the
RootLayoutinapp/layout.tsx, making the state available application-wide. - Internally uses
useStateto hold and update the centralwindowsrecord. - A client-side
useEffecthook 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.
2.3: Registration Hook
- Purpose: Allows individual components (acting as windows) to register with the global
context, retrieve their current state, and receive functions to modify that state. -
Mechanism: A custom hook that encapsulates the logic for consuming the context and handling
the registration/unregistration lifecycle automatically using
useEffect. - Returns: Provides the component with its specificwindowState, memoized action functions (minimize,maximize,close,restore), and a requiredisRegisteredboolean flag to ensure rendering only occurs safely after client-side hydration and registration is complete.
Key Implementation Points
- Hydration Safety: Always check the
isRegisteredflag from the hook before rendering state-dependent UI to prevent server/client mismatches. Render default/loading state untilisRegisteredistrue. - Layout Control: Each window component is responsible for its own layout styling (e.g.,position: fixed, dimensions, z-index) based on itswindowState. - Minimized/Closed State: When minimized or closed, components typically rendernull. The separateFloatingRestoreButtonscomponent handles the UI for restoring them. - Targeted Global CSS: Usebody.window-maximized/body.window-minimizedclasses sparingly to adjust shared elements (like site headers) without interfering with individual window component layouts.
3: Server/Client Data Flow
Props passed from Server Components to Client Components must be serializable by React, which is broader than JSON serialization. JSX elements are supported, including server-rendered content passed as children; ordinary functions are not.
Problem: Non-Serializable Data Across Boundaries
React supports JSX elements and other serializable values across the Server-Client boundary,
but not arbitrary class instances or ordinary function props. Server-only modules such as fs
must stay out of the client module graph.
Solution: Keep Server Logic Server-Side
The reliable pattern involves strict separation: 1. Isolate Server Logic: Create utility
modules using import "server-only" to reject imports into the client module graph. These handle
server-specific tasks (DB access, API calls, file reads). "use server" has a different contract:
it marks async Server Functions that clients can invoke through server references; it is not a
server-only import guard. 2. Orchestrate in Server Component: The main Server Component (e.g., a page)
calls these utilities, gathers the necessary data, and ensures it's fully processed into a
React-serializable structure. 3. Pass Clean Data: Pass the processed, React-serializable values as
props to the top-level Client Component wrapper for the feature. 4. Render on Client: The
Client Component receives the clean data and uses it to render its UI sub-components. This
prevents server-only code or non-serializable data from attempting to run on or be sent to the
client.
4: Auto-Scrolling Terminal
Implemented using standard React hooks:
How it Works
useRefobtains a direct reference to the terminal's scrollable container element. -useEffectmonitors changes in the command history (managed in a separateTerminalContext). - When history updates, the effect programmatically sets the container'sscrollTopto itsscrollHeight, ensuring the latest output is always visible.
5: Command Handling
Command processing logic is centralized for maintainability:
How Commands Are Processed
- User input is captured by the terminal component. - A
useTerminalhook takes the submitted command string. - The hook passes the command to a central, asynchronoushandleCommandfunction. handleCommandis responsible for: - Parsing the input (command and arguments). - Matching against predefined commands or known application routes. - Performing the appropriate action—returning formatted text output, calling separate search logic, using Next.js'suseRouterfor client-side navigation, or returning structured error messages.
6: Syntax-Highlighted Code Blocks with PrismJS
Creating responsive, syntax-highlighted code blocks with proper dark mode support required solving several CSS conflicts.
Challenge: Dark Mode Compatibility
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.
6.1: Implementation Approach
- Base Implementation: PrismJS handles the syntax tokenization and adds language-specific
classes to code elements. - Theme Integration: Import a basic PrismJS theme in the root
layout:
tsx // app/layout.tsx import './globals.css'; import './code-blocks.css'; import '../components/ui/code-block/prism-syntax-highlighting/prism.css';- Component Structure: A customCodeBlockReact component provides the macOS-style window UI around the pre/code elements. - Custom Theme Overrides: Dark mode specific styles incode-blocks.cssoverride PrismJS's default theme for dark appearances.
6.2: Solving Dark Mode Text Highlighting
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:
These rules ensure tokens are styled with color only, not backgrounds or shadows.
6.3: Fixing Rounded Corner Rendering
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:
This flatter structure avoids border conflicts and ensures clean rounded corners.
6.4: CSS @apply Issues
Using Tailwind's @apply directive in global CSS caused linting errors. Replace:
Summary
- Global State: React Context in the root layout is effective for managing shared UI state
(like window status) across different routes in the Next.js App Router. - Hydration: A custom
registration hook (like
useRegisteredWindowState) combined with anisRegisteredflag helps safely manage client-side state dependent on context, preventing hydration errors. - Data Flow: Maintain strict separation between server-side data fetching/processing and client-side rendering. Pass React-serializable props (including JSX elements) from Server to Client Components. - Syntax Highlighting: When integrating libraries like PrismJS, focus on simplifying DOM structure and providing explicit theme overrides for dark mode compatibility. - CSS Complexity: Use!importantflags judiciously when working with third-party CSS that conflicts with your theme. Prefer explicit selectors over complex nesting that can lead to specificity wars.





