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.
What you need
Section titled “What you need”One JavaScript runtime, and the GNOME libraries your app itself uses:
| Runtime | Version | Reaches |
|---|---|---|
| GJS | 1.86 or newer | Linux, macOS in part |
| Node.js | 20 or newer | Linux, macOS, Windows |
| Bun | current release | Linux, macOS, Windows |
| Deno | 2 | Linux, 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 CLI
Section titled “Install the CLI”curl -fsSL https://github.com/gjsify/gjsify/releases/latest/download/install.mjs \ -o /tmp/g.mjs && gjs -m /tmp/g.mjs && rm /tmp/g.mjsnpm install -g @gjsify/clibun add -g @gjsify/clideno install -g -A -n gjsify npm:@gjsify/cliAll 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:
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 your first app
Section titled “Create and run your first app”gjsify create hello-gjsify --template gtk-minimalcd hello-gjsifygjsify installgjsify run devnpm create @gjsify/app@latest hello-gjsify -- --template gtk-minimalcd hello-gjsifynpm installnpm run buildnpm run start:nodebunx @gjsify/cli@latest create hello-gjsify --template gtk-minimal --runtime buncd hello-gjsifybun installbun run buildbun run start:bundeno run -A --reload --min-dep-age=0 npm:@gjsify/cli@latest \ create hello-gjsify --template gtk-minimal --runtime denocd hello-gjsifydeno installdeno task builddeno task start:denoA 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.
| Template | What you get |
|---|---|
gtk-minimal | a Gtk.ApplicationWindow declared in Blueprint, no Adwaita |
adw-canvas2d | Adwaita app drawing through the HTML Canvas 2D API |
adw-webgl | Adwaita app rendering with WebGL and three.js |
adw-game | Adwaita game shell on Excalibur.js, WebGL with a Canvas 2D fallback |
cli | command-line tool built on yargs |
web-server-hono | HTTP server on Hono |
web-server-express | HTTP 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
Section titled “What the CLI generated”hello-gjsify/├── src/│ └── index.ts # Gtk.Application entry point├── package.json # build / start / dev scripts wired to the gjsify CLI├── tsconfig.json└── README.mdThe scripts are the whole build system:
"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:
gjsify run build # bundle both targetsgjsify run build:gjs # only dist/index.gjs.jsgjsify run start # launch it on GJSgjsify run dev # watch src/, rebuild + relaunch on every savegjsify run check # type-check without emittingnpm run build # bundle both targetsnpm run build:node # only dist/index.node.mjsnpm run start:node # launch it on Node.jsnpm run check # type-check without emittingbun run build # bundle both targetsbun run build:node # only dist/index.node.mjsbun run start:bun # launch it on Bunbun run check # type-check without emittingdeno task build # bundle both targetsdeno task build:node # only dist/index.node.mjsdeno task start:deno # launch it on Denodeno task check # type-check without emittingstart 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.
Add a button
Section titled “Add a button”Open src/index.ts. It is plain GTK reached through gi://, with no gjsify
abstraction in the way:
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:
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.
Use Node.js and Web APIs
Section titled “Use Node.js and Web APIs”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:
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:
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:
gjsify build … --globals auto,matchMedia# or a whole group:gjsify build … --globals auto,domThe CLI Reference has the full list.
If it did not start
Section titled “If it did not start”Ask the CLI what is missing:
gjsify system-checknpx @gjsify/cli@latest system-checkbunx @gjsify/cli@latest system-checkdeno run -A npm:@gjsify/cli@latest system-checkIt 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:
sudo dnf install gjs gtk4-devel libadwaita-devel libsoup3-devel \ gobject-introspection-devel meson pkgconf-pkg-configsudo apt install gjs libgtk-4-dev libadwaita-1-dev libsoup-3.0-dev \ gobject-introspection libgirepository1.0-dev meson pkg-configsudo pacman -S gjs gtk4 libadwaita libsoup3 \ gobject-introspection meson pkgconfOn 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:
gjsis 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 foundmeans the launcher directory is not on yourPATH:~/.local/binafter the GJS bootstrap,~/.bun/binafter Bun,~/.deno/binafter Deno.- A second window never appears. Two apps that claim the same
applicationIdare one app to GTK, whichever runtime they are on. Give each project its own, as described above.
Next steps
Section titled “Next steps”- Widget Gallery: every Adwaita component, with live previews
- Native Adwaita Apps: navigation, dialogs, toasts, the app shell
- Runtimes: build the same source for GJS, Node.js, Bun, Deno or the browser
- Storybook: browse and drive your own widgets
- Ship a Flatpak: put your app on Flathub
- One-line installer: let users install your app with
curl - CLI Reference: every command and flag
- Packages: the Node.js, Web and DOM APIs gjsify implements