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.

Every command block on this page has a tab per runtime — GJS, Node.js, Bun, Deno — and the tab you pick follows you across the rest of the site.

One JavaScript runtime, and the GNOME libraries your app uses. On Linux, GTK 4 and libadwaita come from your distribution.

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

On macOS and Windows the Node, Bun and Deno route brings its own GTK, so there is nothing to install by hand.

Where GTK comes from on macOS and Windows

@gjsify/gtk-runtime-darwin-arm64, -darwin-x64 and -win32-x64 carry it, and @gjsify/node-gi loads gi:// out of them. 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 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, so add that directory to your PATH if the shell cannot find it: ~/.local/bin after the GJS bootstrap, your npm prefix after npm, ~/.bun/bin after Bun, ~/.deno/bin after Deno.

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.

Why the @latest tag matters

npm create, bunx and deno run all reuse a cached copy of an unpinned package. So an unpinned npm create @gjsify/app can scaffold from a scaffolder you last used months ago, whose templates predate build:node and the start:node script. You find that out four commands later, as a “missing script” error with no hint about where it came from. The tag forces a fresh resolve.

Deno applies a second rule on top: it refuses any version published in the last 24 hours and takes the previous one instead, without saying so. That is why its command carries --reload --min-dep-age=0. Both rules, and how to see which version you actually got, are on Which version do npx, bunx and deno run give you?

Which runtime and template the CLI picks

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.

The templates
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.

What the CLI generated
hello-gjsify
hello-gjsify/
├── src/
│ ├── index.ts # Gtk.Application entry point
│ ├── main-window.ts # the window class, no widget construction
│ └── main-window.blp # the window's widget tree, declared in Blueprint
├── 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. Give each project its own: when two running apps claim one name, GTK treats the second launch as a remote instance of the first and exits 0 with no window of its own.

Give the label a name in src/main-window.blp so TypeScript can reach it, and add the button beside it. The caption goes through _() because a string set in a template is what xgettext can see; a string set from TypeScript is not:

Code
Gtk.Label title {
label: _("Hello from gjsify!");
}
Gtk.Button hello {
label: _("Say hello");
styles [
"suggested-action",
]
}

Then wire the click in src/main-window.ts, naming both children in InternalChildren and keeping the handler in the constructor:

TypeScript
export class MainWindow extends Gtk.ApplicationWindow {
declare private _hello: Gtk.Button;
declare private _title: Gtk.Label;
static {
GObject.registerClass(
{
GTypeName: 'MainWindow',
Template,
InternalChildren: ['hello', 'title'],
},
this,
);
}
constructor(application: Gtk.Application) {
super({ application });
this._hello.connect('clicked', () => this._title.set_label('Hello yourself!'));
}
}

The split is the point: the template declares what the window is, TypeScript decides what it does. A list whose length is not known until runtime is still filled from TypeScript — declare the container in the .blp and append to it here.

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.

The system libraries to install

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.