TutorialsOct 21, 202511 min read

Set up a live code editor in Next.js with CircleCI

James Oluwaleye

Software Developer

Interactive playgrounds have changed the way developers learn and experiment with code. Instead of having to copy and paste code into a separate Read–Eval–Print Loop (REPL) or local environment, users can write, edit, and run code directly within the tutorial or application interface. Adding this type of editor to a Next.js app makes it more engaging and helps users understand better by eliminating the need to switch between different tools.

However, just having interactivity isn’t enough. Without a strong CI/CD pipeline, keeping code quality high and ensuring smooth deployments can be difficult. Every change, no matter how small, carries the risk of causing errors or inconsistencies. By automating the process with CircleCI, you can ensure that every change goes through the same checks and deployments like linting, testing, building, and publishing so your live editor works well and your production site remains stable.

Prerequisites

Before you start, make sure you have:

Project setup

To start creating your live code editor, you’ll first set up a new project using the official create-next-app command line tool. This tool helps you quickly create a full Next.js project with everything ready for you to start developing right away. In this tutorial, you’ll use the App Router, TypeScript, ESLint, and Tailwind CSS to make sure your code is safe, well-organized, and your user interface looks good quickly.

Open your terminal and type in this command:

npx create-next-app@latest live-editor --typescript --eslint --app

During setup, choose these options:

  • Would you like to use Tailwind CSS? Select Yes
  • Would you like your code inside a src/ directory? Select No
  • Would you like to use Turbopack for next dev? Select No
  • Would you like to customize the import alias (@/* by default)? Select No

Installation setup

Once you finish, the command line tool will create a folder called live-editor. This folder comes with a Next.js application that has TypeScript support for better type safety and helpful suggestions in your code editor, ESLint for checking code quality and keeping formatting consistent, Tailwind CSS already set up for styling, and a modern file organization using the new App Router.

Now, open the project in your code editor and check that everything is working. Run this command:

npm run dev

Go to http://localhost:3000 in your web browser to review the default Next.js landing page with Tailwind styling already applied. This strong setup lets you focus on building the live code editor without needing to manually set up any extra styling or code-checking tools. With everything ready, you can start building the live editor features.

Embedding the live code editor

The main feature of your application is the ability to review and edit code live in the browser. To make this happen, you will use the Monaco Editor, which is the same editor used in Visual Studio Code, along with an iframe that shows the user’s output in real-time. You will set up three editors: one for HTML, one for CSS, and one for JavaScript. As the user types in any of these editors, the code is combined into a single HTML document and the result displayed immediately in the preview window. This creates a smooth, split-screen coding experience that developers will find intuitive.

To get started, you need to install the required tools for the Monaco Editor:

npm install @monaco-editor/react

Now, create a new folder inside the app directory called components. In the newly created folder, create a new file named LiveEditor.tsx. Add this code:

"use client";

import Editor from "@monaco-editor/react";
import { useEffect, useRef, useState } from "react";

export default function LiveEditor() {
    const iframeRef = useRef<HTMLIFrameElement>(null);
    const [code, setCode] = useState({
        html: "<h1>Hello, World!</h1>",
        css: "h1 { color: red; }",
        javascript: 'console.log("JS works!");',
    });

    // Update iframe output when any of the three code inputs change
    useEffect(() => {
        const htmlDoc = `
        <html>
            <head>
            <style>${code.css}</style>
            </head>
            <body>
            ${code.html}
            <script>${code.javascript}<\/script>
            </body>
        </html>
        `;
        const blob = new Blob([htmlDoc], { type: "text/html" });
        const url = URL.createObjectURL(blob);
        if (iframeRef.current) iframeRef.current.src = url;
    }, [code]);

    const handleChange =
        (lang: "html" | "css" | "javascript") => (value: string | undefined) => {
        if (!value) return;
        setCode((prev) => ({ ...prev, [lang]: value }));
        };

    return (
        <>
        <h1 className=" text-4xl pt-10 px-20">Live Editor</h1>
        <p className="px-20 text-sm">Support HTML, CSS, JavaScript</p>

        <div className="w-full h-screen p-20 pt-10 pb-40 grid grid-cols-2 grid-rows-2 gap-4">
            <div className="flex flex-col border rounded overflow-hidden">
            <div className="bg-gray-900 text-white text-xs p-2">HTML</div>
            <Editor
                height="100%"
                defaultLanguage="html"
                language="html"
                theme="vs-dark"
                value={code.html}
                onChange={handleChange("html")}
            />
            </div>
            <div className="flex flex-col border rounded overflow-hidden">
            <div className="bg-gray-900 text-white text-xs p-2">CSS</div>
            <Editor
                height="100%"
                defaultLanguage="css"
                language="css"
                theme="vs-dark"
                value={code.css}
                onChange={handleChange("css")}
            />
            </div>
            <div className="flex flex-col border rounded overflow-hidden">
            <div className="bg-gray-900 text-white text-xs p-2">JavaScript</div>
            <Editor
                height="100%"
                defaultLanguage="javascript"
                language="javascript"
                theme="vs-dark"
                value={code.javascript}
                onChange={handleChange("javascript")}
            />
            </div>
            <div className="bg-white border rounded shadow overflow-auto">
            <iframe
                ref={iframeRef}
                className="w-full h-full border-0"
                title="Live Output"
            />
            </div>
        </div>
        </>
    );
}

This component tracks HTML, CSS, and JavaScript code using state for the editors. When any value changes, a new HTML document is generated with <style> and <script> tags. This combined document is turned into a Blob and displayed in an iframe using a generated URL, which means you can review the results in real-time.

The editors and the output preview are arranged in a responsive 2x2 grid using Tailwind CSS, creating a user-friendly workspace. HTML and CSS are at the top, with JavaScript and the preview at the bottom. Now you have a strong and visually familiar space to test and preview code snippets in three important web languages.

To display the editor, include it in the app/page.tsx file. Replace the existing code with this:

import LiveEditor from "./components/LiveEditor";

export default function Home() {
  return (
    <main className="min-h-screen  bg-black text-white">
      <LiveEditor />
    </main>
  );
}

Run the code to test the editor:

npm run dev

The live editor will show some default code.

Live editor preview

Here’s a working set of HTML, CSS, and JavaScript to test in your editor:

<div class="container">
  <h1>Hello, Live Editor!</h1>
  <button onclick="handleClick()">Click Me</button>
</div>
body {
  font-family: sans-serif;
  background: #f9f9f9;
  padding: 20px;
}

.container {
  text-align: center;
  padding: 40px;
  border: 2px dashed #888;
  background: white;
}

button {
  background-color: #10b981;
  color: white;
  border: none;
  padding: 12px 24px;
  margin-top: 20px;
  font-size: 16px;
  cursor: pointer;
  border-radius: 4px;
}

button:hover {
  background-color: #059669;
}
function handleClick() {
  alert("You clicked the button!");
  const h1 = document.querySelector("h1");
  h1.textContent = "Thanks for clicking!";
}

Live editor test

Try clicking the Click Me button in the preview pane.

Live editor test

Enforcing code quality with linting

As your codebase expands, especially in an interactive application like a live editor, it’s important to keep it clean, consistent, and bug-free. Each new feature or change should be reviewed against clear guidelines and expectations for how it should work. To help with this, you can use ESLint for catching issues and Prettier for code formatting. These tools help you spot mistakes early and keep everything neat and predictable.

Start by installing the necessary packages:

npm install -D eslint prettier eslint-config-prettier eslint-plugin-prettier

Here’s what you’re installing:

  • eslint checks for issues and enforces rules
  • prettier formats your code automatically
  • eslint-plugin-prettier + eslint-config-prettier connects Prettier to ESLint

Next, open the package.json file. In the scripts section, replace the existing lint script with this:

"lint": "eslint . --ext .ts,.tsx",
"format": "prettier --write ."

This allows you to quickly lint and format your code using the commands npm run lint and npm run format.

Now, create a new file named .prettierrc in the project root folder. Add this:

{
  "semi": true,
  "singleQuote": false,
  "tabWidth": 2,
  "trailingComma": "es5"
}

This sets up Prettier to use semicolons, double quotes, 2-space tabs, and trailing commas where needed.

Next, configure ESLint. Open the eslint.config.mjs file and replace the existing code with this:

/* eslint-disable prettier/prettier */
import { FlatCompat } from "@eslint/eslintrc";

const compat = new FlatCompat({ baseDirectory: process.cwd() });

const config = [
  // Extend Next.js and Prettier recommended configs
  ...compat.extends(
    "next/core-web-vitals",
    "plugin:prettier/recommended"
  ),

  // Your global rules
  {
    rules: {
      "prettier/prettier": "error"
    }
  },

  // Relax strict rules for test files
  {
    files: ["**/__tests__/**/*.{ts,tsx}", "**/*.test.tsx"],
    rules: {
      "@typescript-eslint/no-require-imports": "off",
      "@typescript-eslint/no-explicit-any": "off",
      "react/display-name": "off"
    }
  }
];

export default config;

This setup instructs ESLint to follow Next.js and Prettier best practices, show errors when code doesn’t follow Prettier formatting, and relax strict rules in test files so you can write tests more easily.

Replace the contents of the next.config.ts file with this:

import type { NextConfig } from "next";

const isCI = process.env.CI === "true";

const nextConfig: NextConfig = {
  eslint: {
    ignoreDuringBuilds: isCI,
  },
};

export default nextConfig;

This ensures that ESLint won’t block your build in CI environments.

You can now run:

npm run lint
npm run format
  • npm run lint checks your code for style issues
  • npm run format fixes formatting problems automatically

Testing result

Add tests for the live editor component

As your codebase grows, adding a reliable test setup helps you catch regressions early and confidently refactor components. In this section, you’ll set up Jest with React Testing Library and write tests for the LiveEditor component.

First, install the following dev dependencies:

npm install --save-dev jest ts-jest @types/jest identity-obj-proxy @testing-library/react @testing-library/jest-dom jest-environment-jsdom

These will let you:

  • Run tests using Jest
  • Use TypeScript with Jest (ts-jest)
  • Test React components with React Testing Library
  • Assert on DOM behavior with jest-dom
  • Stub CSS imports using identity-obj-proxy

Next, you need to configure Jest so it works well with TypeScript, React, and browser-like APIs. Create a jest.config.js file at the root of your project and add the following:

// jest.config.js
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  preset: 'ts-jest',                // use ts-jest preset
  testEnvironment: 'jsdom',         // simulate browser environment
  moduleFileExtensions: [
    'ts', 'tsx', 'js', 'jsx', 'json'
  ],
  transform: {
    '^.+\\.(ts|tsx|js|jsx)$': 'ts-jest'
  },
  moduleNameMapper: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy'
  },
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  testMatch: [
    '<rootDir>/**/__tests__/**/*.(ts|tsx|js|jsx)',
    '<rootDir>/**/*.(spec|test).(ts|tsx|js|jsx)'
  ],
  // Avoid ignoring your transform for node_modules (if you need to transform ESM deps, adjust this)
  transformIgnorePatterns: ['<rootDir>/node_modules/']
};

This config tells Jest to use the ts-jest preset, simulate a browser environment using jsdom, and support CSS module mocking and test file patterns.

Then, create a new file named jest.setup.ts in the project root folder. Set up global test behavior by added:

// jest.setup.ts

import '@testing-library/jest-dom';

// Polyfill createObjectURL for tests
Object.defineProperty(global.URL, 'createObjectURL', {
  writable: true,
  value: jest.fn().mockReturnValue('blob://test'),
});

Add @testing-library/jest-dom matchers and mock URL.createObjectURL, which is used in the LiveEditor component but doesn’t exist in the test environment.

Make sure your tsconfig.json is configured to support JSX transformation by updating the following under compilerOptions:

"jsx": "react-jsx",

Next, create a test file named LiveEditor.test.tsx in a new __tests__ folder at the root of your project. Add this:

// __tests__/LiveEditor.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import LiveEditor from '../app/components/LiveEditor';
import '@testing-library/jest-dom';

// Mock Monaco Editor without JSX
jest.mock('@monaco-editor/react', () => {
  const React = require('react');
  return (props: any) =>
    React.createElement('textarea', {
      'data-testid': `editor-${props.language}`,
      value: props.value,
      onChange: (e: any) => props.onChange(e.target.value),
    });
});

describe('LiveEditor', () => {
  beforeEach(() => {
    // Reset our URL.createObjectURL spy before each test
    jest.resetAllMocks();
  });

  it('renders HTML, CSS, JS editors and output iframe', () => {
    // Spy on createObjectURL so we can track calls
    jest.spyOn(URL, 'createObjectURL').mockReturnValue('blob://test');

    render(<LiveEditor />);

    expect(screen.getByTestId('editor-html')).toBeInTheDocument();
    expect(screen.getByTestId('editor-css')).toBeInTheDocument();
    expect(screen.getByTestId('editor-javascript')).toBeInTheDocument();
    expect(screen.getByTitle('Live Output')).toBeInTheDocument();

    // Initial render should call createObjectURL once
    expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
  });

  it('calls createObjectURL again when HTML code changes', () => {
    const spy = jest
      .spyOn(URL, 'createObjectURL')
      .mockReturnValue('blob://test');

    render(<LiveEditor />);

    // initial call
    expect(spy).toHaveBeenCalledTimes(1);

    // Change the HTML editor content
    const htmlEditor = screen.getByTestId(
      'editor-html'
    ) as HTMLTextAreaElement;
    fireEvent.change(htmlEditor, {
      target: { value: '<h2>Test Heading</h2>' },
    });

    // After change, effect should run again
    expect(spy).toHaveBeenCalledTimes(2);
  });
});

These tests verify that the LiveEditor component renders all code panels and updates the iframe output when code changes. To keep things simple and testable, you’ll mock the Monaco Editor with a basic <textarea> and stub URL.createObjectURL, both of which are hard to test directly in a Node.js environment.

Note that Jest runs in Node using jsdom, so it can simulate DOM updates but won’t reflect actual visual styling like colors or layout. These limitations are fine here since we’re mostly verifying interactivity and dynamic behavior.

Next, open the package.json file. You’ll add a script to help you to easily run tests using the command npm run test:

"test": "jest"

Run the tests:

npm run test

Results show whether the LiveEditor component works and updates the iframe as expected.

Testing result

Automating with CircleCI

Now that your tests are running successfully locally, it’s time to automate the process. By setting up Continuous Integration (CI) with CircleCI, you can make sure that every change made to your codebase is automatically checked, including formatting, linting, and testing, before it’s considered production-ready.

CI helps catch bugs early, enforce code standards, and keep your project stable over time without needing to manually run checks. In this section, you’ll learn how to configure a CircleCI pipeline, commit it to GitHub, and connect your repository in the CircleCI dashboard.

Start by creating a new file named .circleci/config.yml in your project root folder. Add this code:

version: 2.1

orbs:
  node: circleci/node@7.1.0

jobs:
  lint-and-test:
    executor:
      name: node/default
      tag: "22.13.1"
    steps:
      - checkout
      - run: npm ci
      - run: npm run format
      - run: npm run lint -- --fix
      - run: npm test

workflows:
  lint-and-test:
    jobs:
      - lint-and-test

This CircleCI configuration uses the circleci/node orb to simplify Node-related setup. The lint-and-test job checks out the code, installs dependencies using npm ci (for clean and consistent installs), formats the code, runs ESLint with auto-fix, and finally runs the test suite with the command npm test. This job is triggered in the lint-and-test workflow.

Now that everything is fully configured locally, upload your project to a GitHub repository and create a project on CircleCi.

You can now trigger your pipeline manually and it should build successfully:

Successful pipeline build

Sandboxing and input safety

Now that the live editor is up and running, tests are in place, and CI is running smoothly, there’s one last critical concept: safety. Since your editor allows users to write HTML, CSS, and JavaScript inside an <iframe>, you’ll need to think about what kind of code can be run and what effects it might have. If you don’t limit what the iframe can do, any JavaScript a user enters could access your site’s cookies or local storage, open annoying popups, redirect the main page, create endless loops that crash the browser tab. In short, you need to keep that code separate and safe.

The easiest and most effective way to keep user code contained is to add the the sandbox attribute to the iframe:

<iframe
  ref={iframeRef}
  className="w-full h-full border-0"
  title="Live Output"
  sandbox="allow-scripts"
/>

This setup allows JavaScript to run but blocks everything else, like popups (window.open), redirecting the main page, access to cookies or local storage, navigation outside the iframe and forms. By not including allow-same-origin, the iframe is treated as a separate entity. This means the code inside it can’t interact with your main app. Only add allow-same-origin if you are completely sure the code inside the iframe is safe. Usually, it isn’t. Even in a sandboxed iframe, it’s smart to check the input before loading it. There are two quick checks you can add.

  1. To avoid slow performance or someone pasting huge amounts of code, set a size limit:
if (
  code.html.length + code.css.length + code.javascript.length >
  100_000
) {
  console.warn("Code too large, skipping update.");
  return;
}
  1. Encourage users to write JavaScript in the designated JavaScript area, not in the HTML area:
if (/<script/i.test(code.html)) {
  console.warn("Don't include <script> in the HTML panel.");
  return;
}

This ensures JavaScript is kept where it’s supposed to be, making the output more predictable. Here’s your updated useEffect with both safety checks:

useEffect(() => {
  const total = code.html.length + code.css.length + code.javascript.length;
  if (total > 100_000) return;

  if (/<script/i.test(code.html)) return;

  const htmlDoc = `
    <html>
      <head>
        <style>${code.css}</style>
      </head>
      <body>
        ${code.html}
        <script>${code.javascript}<\/script>
      </body>
    </html>
  `;

  const blob = new Blob([htmlDoc], { type: "text/html" });
  const url = URL.createObjectURL(blob);
  if (iframeRef.current) iframeRef.current.src = url;
}, [code]);

By combining sandbox="allow-scripts" for strict iframe separation, code size limits and HTML input checks you create a live editor that is safe by default and much harder to misuse even though it runs user code in the browser. This is a strong foundation for a live coding tool, whether you’re making a playground, an educational platform, or a custom development tool. Here is your final LiveEditor.tsx:

"use client";

import Editor from "@monaco-editor/react";
import { useEffect, useRef, useState } from "react";

export default function LiveEditor() {
  const iframeRef = useRef<HTMLIFrameElement>(null);
  const [code, setCode] = useState({
    html: "<h1>Hello, World!</h1>",
    css: "h1 { color: red; }",
    javascript: 'console.log("JS works!");',
  });

  // Update iframe output when any of the three code inputs change
  useEffect(() => {
    const total = code.html.length + code.css.length + code.javascript.length;
    if (total > 100_000) return;

    if (/<script/i.test(code.html)) return;

    const htmlDoc = `
    <html>
      <head>
        <style>${code.css}</style>
      </head>
      <body>
        ${code.html}
        <script>${code.javascript}<\/script>
      </body>
    </html>
  `;

    const blob = new Blob([htmlDoc], { type: "text/html" });
    const url = URL.createObjectURL(blob);
    if (iframeRef.current) iframeRef.current.src = url;
  }, [code]);

  const handleChange =
    (lang: "html" | "css" | "javascript") => (value: string | undefined) => {
      if (!value) return;
      setCode((prev) => ({ ...prev, [lang]: value }));
    };

  return (
    <>
      <h1 className=" text-4xl pt-10 px-20">Live Editor</h1>
      <p className="px-20 text-sm">Support HTML, CSS, JavaScript</p>

      <div className="w-full h-screen p-20 pt-10 pb-40 grid grid-cols-2 grid-rows-2 gap-4">
        <div className="flex flex-col border rounded overflow-hidden">
          <div className="bg-gray-900 text-white text-xs p-2">HTML</div>
          <Editor
            height="100%"
            defaultLanguage="html"
            language="html"
            theme="vs-dark"
            value={code.html}
            onChange={handleChange("html")}
          />
        </div>
        <div className="flex flex-col border rounded overflow-hidden">
          <div className="bg-gray-900 text-white text-xs p-2">CSS</div>
          <Editor
            height="100%"
            defaultLanguage="css"
            language="css"
            theme="vs-dark"
            value={code.css}
            onChange={handleChange("css")}
          />
        </div>
        <div className="flex flex-col border rounded overflow-hidden">
          <div className="bg-gray-900 text-white text-xs p-2">JavaScript</div>
          <Editor
            height="100%"
            defaultLanguage="javascript"
            language="javascript"
            theme="vs-dark"
            value={code.javascript}
            onChange={handleChange("javascript")}
          />
        </div>
        <div className="bg-white border rounded shadow overflow-auto">
          <iframe
            ref={iframeRef}
            className="w-full h-full border-0"
            title="Live Output"
            sandbox="allow-scripts"
          />
        </div>
      </div>
    </>
  );
}

You can access the full code on GitHub.

Conclusion

You’ve built a powerful and flexible live code editor using Next.js, complete with support for HTML, CSS, and JavaScript. Along the way, you added linting with ESLint, unit tests with Jest, and automated workflows using CircleCI and GitHub. Together, these tools create a stable and maintainable setup where every change is automatically tested and cleaned up before it’s merged.

Looking ahead, there are many exciting directions you can take your project. You could add a simple Next.js API with MongoDB (or Prisma + PostgreSQL) to let users save and get back their code. You can even create shareable links so others can explore and modify someone’s code. You could add support for in-browser Python using Pyodide, or use remote environments (with Docker) for languages like Go, Ruby, or Java. You’ll need to think about security and performance, but the systems you’ve set up for continuous integration, deployment, and testing will work well as you grow. With this strong foundation in continuous integration and deployment, along with a solid and secure editor, your live coding environment is ready for use and future growth.


James Oluwaleye is a software developer and technical writer with a background in Computer Engineering. He specializes in web development using modern JavaScript frameworks, authors technical articles about cloud computing and data systems, and teaches computer science to help foster the next generation of developers.