Skip to content

Getting Started

Install the CLI, scaffold a project, run it. About a minute later a GTK 4 window is on screen, running your own TypeScript.

First, pick your runtime. gjsify targets four: GJS, Node.js, Bun and Deno. Every command block on this page has a tab per runtime, and the tab you pick follows you across the rest of the site. If a step fails, jump to If it did not start.

One JavaScript runtime, and the GNOME libraries your app itself uses:

RuntimeVersionReaches
GJS1.86 or newerLinux, macOS in part
Node.js20 or newerLinux, macOS, Windows
Buncurrent releaseLinux, macOS, Windows
Deno2Linux, macOS, Windows

On Linux, GTK 4 and libadwaita come from your distribution and every runtime uses them. The package lists are further down.

On macOS and Windows the Node, Bun and Deno route brings its own GTK. @gjsify/gtk-runtime-darwin-arm64, -darwin-x64 and -win32-x64 carry it, and @gjsify/node-gi loads gi:// out of them, so there is nothing to install by hand. Windows has no gjs binary at all, so take Node, Bun or Deno there. macOS has one from Homebrew, but nobody verifies the GJS route there end to end, which is why Platform Support calls that column partial.

Install the gjsify CLI
Terminal
curl -fsSL https://github.com/gjsify/gjsify/releases/latest/download/install.mjs \
-o /tmp/g.mjs && gjs -m /tmp/g.mjs && rm /tmp/g.mjs
Terminal
npm install -g @gjsify/cli
Terminal
bun add -g @gjsify/cli
Terminal
deno install -g -A -n gjsify npm:@gjsify/cli

All four give you the same gjsify command, and the runtime you installed with becomes the one it builds for by default.

Each route puts the launcher somewhere different: ~/.local/bin for the GJS bootstrap (which runs on stock gjs and needs no Node.js), your npm prefix, ~/.bun/bin for Bun, ~/.deno/bin for Deno. If that directory is not on your PATH yet, add it:

Terminal
export PATH="$HOME/.local/bin:$PATH"

Updating, pinning a version, adding the CLI to one project instead of globally, and uninstalling are on the Install & Update page.

Create and run
Terminal
gjsify create hello-gjsify --template gtk-minimal
cd hello-gjsify
gjsify install
gjsify run dev
Terminal
npm create @gjsify/app@latest hello-gjsify -- --template gtk-minimal
cd hello-gjsify
npm install
npm run build
npm run start:node
Terminal
bunx @gjsify/cli@latest create hello-gjsify --template gtk-minimal --runtime bun
cd hello-gjsify
bun install
bun run build
bun run start:bun
Terminal
deno run -A --reload --min-dep-age=0 npm:@gjsify/cli@latest \
create hello-gjsify --template gtk-minimal --runtime deno
cd hello-gjsify
deno install
deno task build
deno task start:deno

A window titled hello-gjsify opens with a label in it. That window is your src/index.ts, bundled into one file and executed by the runtime you picked.

In a terminal the CLI asks which runtime to set the project up for, opening on the one it is running on, and then which package manager to install with. In a script it takes the same defaults and prints what it took. Your answer changes no scaffolded byte: every template ships a bundle for every runtime, so it only decides which start script the printed next steps name. Set it outright with --runtime gjs|node|bun|deno.

That is why the Bun and Deno commands above pass it. bunx and deno run fetch an npm bin and start it on Node, so the CLI’s “which runtime am I on?” answers node, and its next steps would name npm. Correct about itself, and not what you asked for.

Leave --template off and the CLI asks which one you want too. That one has no defensible default, so in a script --template is required.

TemplateWhat you get
gtk-minimala Gtk.ApplicationWindow declared in Blueprint, no Adwaita
adw-canvas2dAdwaita app drawing through the HTML Canvas 2D API
adw-webglAdwaita app rendering with WebGL and three.js
adw-gameAdwaita game shell on Excalibur.js, WebGL with a Canvas 2D fallback
clicommand-line tool built on yargs
web-server-honoHTTP server on Hono
web-server-expressHTTP server on Express

All seven run on GJS, Node.js, Bun and Deno. Each one declares that set in gjsify.example.runtimes, and gjsify run --runtime <name> checks against it. Asking for a runtime a project has no bundle for fails with a message instead of crashing somewhere inside one.

hello-gjsify
hello-gjsify/
├── src/
│ └── index.ts # Gtk.Application entry point
├── package.json # build / start / dev scripts wired to the gjsify CLI
├── tsconfig.json
└── README.md

The scripts are the whole build system:

JSON
"scripts": {
"clear": "gjsify clear dist tsconfig.tsbuildinfo",
"check": "gjsify tsc --noEmit",
"build": "gjsify run build:gjs && gjsify run build:node",
"build:gjs": "gjsify build src/index.ts --app gjs --outfile dist/index.gjs.js",
"build:node": "gjsify build src/index.ts --app node --outfile dist/index.node.mjs",
"start": "gjsify run dist/index.gjs.js",
"start:node": "gjsify run dist/index.node.mjs --runtime node",
"start:bun": "gjsify run dist/index.node.mjs --runtime bun",
"start:deno": "gjsify run dist/index.node.mjs --runtime deno",
"dev": "gjsify dev --runtime gjs"
}

Two bundles out of one src/index.ts. dist/index.gjs.js is what GJS runs. dist/index.node.mjs is shared by Node, Bun and Deno, because Node-API is their common ABI and none of them needs a target of its own. Both builds name --app explicitly, so the target cannot silently follow whichever runtime invoked the build.

dev is the loop you will spend the most time in. It watches src/, rebuilds on every change and relaunches the app, with no separate watcher to install; see gjsify dev. One wrinkle: the scaffolded script names --runtime gjs outright, whichever runtime you set the project up for. On Node, Bun or Deno, run gjsify dev --runtime node (or bun, or deno) yourself, or edit the script once to say so.

Run them with whichever toolchain you picked above:

Build and run
Terminal
gjsify run build # bundle both targets
gjsify run build:gjs # only dist/index.gjs.js
gjsify run start # launch it on GJS
gjsify run dev # watch src/, rebuild + relaunch on every save
gjsify run check # type-check without emitting
Terminal
npm run build # bundle both targets
npm run build:node # only dist/index.node.mjs
npm run start:node # launch it on Node.js
npm run check # type-check without emitting
Terminal
bun run build # bundle both targets
bun run build:node # only dist/index.node.mjs
bun run start:bun # launch it on Bun
bun run check # type-check without emitting
Terminal
deno task build # bundle both targets
deno task build:node # only dist/index.node.mjs
deno task start:deno # launch it on Deno
deno task check # type-check without emitting

start and dev name the GJS bundle. start:node, start:bun and start:deno name the shared one. That is the only difference between the four columns.

Launch through gjsify run rather than calling gjs, node, bun or deno yourself. It sets LD_LIBRARY_PATH and GI_TYPELIB_PATH so the bundle finds native packages such as @gjsify/webgl, which is why every start:* script above goes through it. For a GJS bundle, gjsify info prints those paths if you want to export them by hand.

Open src/index.ts. It is plain GTK reached through gi://, with no gjsify abstraction in the way:

TypeScript
import Gtk from 'gi://Gtk?version=4.0';
import Gio from 'gi://Gio?version=2.0';
const app = new Gtk.Application({
applicationId: 'org.gjsify.hello-gjsify',
flags: Gio.ApplicationFlags.FLAGS_NONE,
});

Nothing in that file names a runtime. The same bytes build for GJS, Node.js, Bun and Deno, and so does every TypeScript sample on this site, the widget gallery included.

applicationId is the session-bus name your app claims, and every project needs its own. Change it if you copy this file into a second project. When two running apps claim one name, GTK treats the second launch as a remote instance of the first, forwards its activate to that app’s window, and exits 0 with no window and no error of its own.

Inside the activate handler, next to the label the template already creates:

TypeScript
const button = new Gtk.Button({ label: 'Say hello' });
button.add_css_class('suggested-action');
button.connect('clicked', () => title.set_label('Hello yourself!'));
box.append(button);

Build and start again and the button is there. For the Adwaita widgets (header bars, boxed lists, toasts, view switchers) see the Widget Gallery, and for a full app shell with navigation and dialogs see Native Adwaita Apps.

Write ordinary Node.js and Web code in the same file. The build resolves each node:* import and injects the globals your code touches, so you maintain no import list of your own:

TypeScript
import { readFileSync } from 'node:fs'
import { createServer } from 'node:http'
const html = readFileSync('index.html', 'utf-8')
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(html)
})
const port = Number(process.env.PORT ?? 8080)
server.listen(port, () => {
console.log(`Server running on http://localhost:${port}`)
})

Where each import lands depends on the target, and that is the whole difference. In a --app gjs bundle it lands on gjsify’s implementation, each one backed by a GNOME library: node:fs on Gio.File, node:http on Soup.Server, process.env on GLib.getenv(). In a --app node bundle it lands on Node, Bun or Deno’s own, because all three already have it. Your source does not change.

Web APIs work the same way:

TypeScript
const response = await fetch('https://api.example.com/data')
const data = await response.json()
const ws = new WebSocket('wss://echo.example.com')
ws.addEventListener('message', (event) => console.log(event.data))

Packages lists what is implemented.

Global detection missed something?

It is rare, and it happens with libraries that reach globalThis through another object, which hides the access from static analysis. Keep auto on and name the identifier you need:

Terminal
gjsify build --globals auto,matchMedia
# or a whole group:
gjsify build --globals auto,dom

The CLI Reference has the full list.

Ask the CLI what is missing:

Check your system
Terminal
gjsify system-check
Terminal
npx @gjsify/cli@latest system-check
Terminal
bunx @gjsify/cli@latest system-check
Terminal
deno run -A npm:@gjsify/cli@latest system-check

It prints a line per dependency, names the @gjsify/* package that wants each optional one, and warns when your @girs/* types describe a newer library than the one installed. It checks one fixed list, gjs included, so on a machine without it that row reads as missing even though a --app node build never asks for it.

To install the required set on Linux:

Install the system libraries
Terminal
sudo dnf install gjs gtk4-devel libadwaita-devel libsoup3-devel \
gobject-introspection-devel meson pkgconf-pkg-config
Terminal
sudo apt install gjs libgtk-4-dev libadwaita-1-dev libsoup-3.0-dev \
gobject-introspection libgirepository1.0-dev meson pkg-config
Terminal
sudo pacman -S gjs gtk4 libadwaita libsoup3 \
gobject-introspection meson pkgconf

On macOS and Windows there is no equivalent list. @gjsify/node-gi loads GTK from @gjsify/gtk-runtime-<platform>-<arch> instead, which npm, Bun and Deno install for you. Platform Support has the per-package picture.

Three things that trip people up:

  • gjs is too old. 1.86 is the floor. Fedora 43+, Arch and Debian forky/sid ship it; Debian 13 (trixie) ships 1.82.3 and will not work.
  • gjsify: command not found means the launcher directory is not on your PATH: ~/.local/bin after the GJS bootstrap, ~/.bun/bin after Bun, ~/.deno/bin after Deno.
  • A second window never appears. Two apps that claim the same applicationId are one app to GTK, whichever runtime they are on. Give each project its own, as described above.