The fullstack React framework for the desktop. Real Next.js on the inside, a native Electron shell on the outside.
π nextopapp.vercel.app
NextOP bundles Next.js + Electron to build desktop applications for the React ecosystem.
It combines the Next.js developer experience (App Router, SSR, Server Components, API routes,
next/image) with Electron's native capabilities (filesystem, shell, notifications, clipboard,
secure storage, native menus, multi-window).
Status: early / experimental. API may still change. Platform focus: Windows (PowerShell); macOS/Linux work but are less validated.
npx create-nextop-app my-app
cd my-app
npm run dev # compiles the Electron layer + launches the in-process Next dev server
npm run build # next build + Electron compile + electron-builder package β release/NextOP does not statically export Next.js. Instead it runs a live Next.js HTTP server inside the Electron main process:
Electron app.whenReady()
β startNextServer(): next({ dir, dev }) + http.createServer β 127.0.0.1:<port>
β BrowserWindow.loadURL("http://127.0.0.1:<port>")
Consequence: every Next.js feature works β App Router, SSR, Server Components, Route Handlers,
Server Actions, middleware, next/image. This is the differentiator versus static-export-based
desktop wrappers.
Cost: Chromium + Node + a live Next server is the heaviest runtime profile among comparable frameworks. This weight is inherent to the design, not a bug.
The same runtime model is used for dev (next({ dir: cwd, dev: true })) and production
(next({ dir: app.getAppPath(), dev: false }) against the prebuilt .next output). The server binds
to 127.0.0.1 only (never the LAN); the port prefers 3000 and falls back to an OS-assigned free
port on EADDRINUSE.
ββββββββββββββββββββββββββββ Electron Main Process ββββββββββββββββββββββββββββ
β app.whenReady() β
β ββ new BrowserWindow({ contextIsolation, sandbox, nodeIntegration:false })β
β ββ registerNextOP(mainWindow, options) β from "nextop-app/main" β
β β ββ ipcMain handlers: fs, shell, clipboard, notification, menu, β
β β β window controls, secure-store, internal windows β
β β ββ navigation guards (will-navigate / setWindowOpenHandler) β
β ββ startNextServer(dir, dev) β live Next.js on 127.0.0.1:<port> β
βββββββββββββββββ²βββββββββββββββββββββββββββββββββββββββββββββββ²ββββββββββββββ
β contextBridge (preload.ts) β http
β window.desktop / window.nextop β
βββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββββββββββββ΄βββββββββββββ
β Renderer (your Next.js app) β
β Hooks: useFs, useWindow, useMenu, useShell, useNotification, β
β useClipboard, useSecureStore β
β Components: <Link> (nextop-app/link), <VirtualList> (nextop-app/...) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
preload.tsexposeswindow.desktop(namespaced helpers + a guarded genericipcRenderer) andwindow.nextop({ openExternal(url) }) viacontextBridge.contextIsolation: true,nodeIntegration: false,sandbox: trueβ the correct Electron security baseline.- Hooks degrade gracefully to
null/ no-op whenwindow.desktopis absent (e.g. rendered on the web / during SSR).
| Package | Role |
|---|---|
create-nextop-app |
CLI that scaffolds a project via npx create-nextop-app |
nextop-app |
Runtime library (React hooks + main-process IPC) and the nextop CLI |
Stack of a freshly scaffolded app: Next.js 16, React 19, Tailwind CSS v4, Electron 39, TypeScript 5, electron-builder 25.
Import from nextop-app/main and call it inside app.whenReady(), after the BrowserWindow is
created.
// electron/main.ts
import { app, BrowserWindow } from "electron"
import path from "path"
import { startNextServer } from "./startNext"
import { registerNextOP } from "nextop-app/main"
let mainWindow: BrowserWindow | null = null
app.whenReady().then(async () => {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: false,
backgroundColor: "#0a0a0a",
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
mainWindow.once("ready-to-show", () => mainWindow?.show())
registerNextOP(mainWindow, {
fs: { mode: "allowed", allowedRoots: [app.getPath("userData")] },
shell: { mode: "none", allowedCommands: [], requireConsent: true },
})
const dev = !app.isPackaged
const nextServer = await startNextServer(dev ? process.cwd() : app.getAppPath(), dev)
await mainWindow.loadURL(`http://127.0.0.1:${nextServer.port}`)
})All hooks import from the package root β import { useFs, useWindow, ... } from 'nextop-app' β and
are safe during SSR / on the web (they no-op or return null when window.desktop is unavailable).
| Hook | Purpose |
|---|---|
useFs() |
Sandboxed readFile / writeFile, scoped by the fs config. |
useWindow() |
minimize / maximize / close / isMaximized / isAvailable. |
useNotification() |
Native OS notifications (showNotification({ title, body })). |
useClipboard() |
readText / writeText for the system clipboard. |
useShell() |
Run an OS executable with an explicit args array. Disabled by default. |
useSecureStore() |
Encrypted key/value secret storage backed by Electron safeStorage. |
useMenu() |
Set the native application menu ([menu, setMenu]). |
'use client'
import { useFs, useWindow } from 'nextop-app'
export default function Page() {
const { writeFile } = useFs()
const { minimize, maximize, close } = useWindow()
return (
<main>
<button onClick={() => writeFile('nextop.txt', 'Created by NextOP')}>Create file</button>
<button onClick={minimize}>Minimize</button>
<button onClick={maximize}>Maximize</button>
<button onClick={close}>Close</button>
</main>
)
}<Link>(nextop-app/link) β anext/linkwrapper withisExternal(opens in the system browser) andtarget="_blank"(opens a secure internal Electron window viainternalOptions).<VirtualList>(nextop-app/virtual-list) β lightweight virtualization that only renders children near the viewport (IntersectionObserver, 300px overscan).
Both the filesystem and shell layers are mode-based with security-first defaults.
| Layer | Modes | Default |
|---|---|---|
Filesystem (useFs) |
'all' (any path) Β· 'allowed' (confined to allowedRoots, traversal blocked) Β· 'none' |
'allowed' |
Shell (useShell) |
'all' Β· 'allowed' (allowlist) Β· 'none'. Always spawn(cmd, args, { shell:false }) β no shell injection. requireConsent shows a native confirmation dialog. |
'none', requireConsent: true |
- Navigation guards:
will-navigateto a non-localhost/127.0.0.1origin is blocked (external links open in the system browser);setWindowOpenHandlerdenies all popups.sandbox: trueon the main window and internal windows. - The Next.js backend runs on the user's machine. It binds to
127.0.0.1only. Never embed central/shared DB credentials in app code or.env(the package ships readable,asar: false) β use a remote API with per-user tokens. The localhost API has no auth by default; add your own to sensitive Route Handlers. - CSP is intentionally not auto-applied β a strict CSP breaks Next.js (HMR
eval, inline styles). Treat it as per-app, opt-in. - IPC channel allowlist:
preload.tsvalidates everyinvoke/send/onagainst an allowlist; user-defined channels are allowed under theapp:prefix convention.
The full API reference β every hook/component, the complete security model, the IPC channel reference, CLI internals, and FAQ β lives in DOCS.md and on the website:
π nextopapp.vercel.app
MIT Β© FlyingTurkman