Set up a live code editor in Next.js with CircleCI
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:
- Node.js and npm installed
- A GitHub account
- A CircleCI account
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
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.
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!";
}
Try clicking the Click Me button in the preview pane.
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 rulesprettier
formats your code automaticallyeslint-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 issuesnpm run format
fixes formatting problems automatically
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.
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:
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.
- 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;
}
- 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.