Why this matters
This replaces .tool-versions and mise install per project. The difference: mise pins "node 22", a flake pins the exact nixpkgs commit, so node, pnpm, biome, and their libc are byte-identical on your laptop, CI and a teammate's machine. One file, committed, no global state.
Concept
What a flake is
A flake.nix is an attrset with inputs (other flakes, pinned in flake.lock) and outputs (a function from the resolved inputs to an attrset of things Nix knows how to use: packages, devShells, nixosModules, and so on).
nix flake init writes a starter:
mkdir demo && cd demo && nix flake initwrote: "/tmp/demo/flake.nix"The template defines packages. You want devShells, so write your own instead.
A dev shell for SvelteKit
{
description = "SvelteKit dev shell";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.${system}.default = pkgs.mkShell {
packages = [
pkgs.nodejs_22
pkgs.pnpm
pkgs.biome
];
shellHook = ''
echo "node $(node --version) | pnpm $(pnpm --version)"
'';
};
};
}Line by line:
inputs.nixpkgs.urlnames where packages come from. Flake inputs are pinned, unlike the registry'snixpkgs#which floats.outputsis a function.selfis this flake;nixpkgsis the fetched input. Attrset pattern, as in Functions.pkgs = nixpkgs.legacyPackages.${system}gives the package set for your CPU. Flakes do not guess the system; you say it.mkShellbuilds a derivation whose only purpose is its environment.packagesgo on PATH.shellHookis bash that runs on entry.devShells.<system>.defaultis the attributenix developlooks for with no arguments.
Add pkgs.turso-cli to packages for a Turso project. Attribute names: see Run, shell, search.
The git rule
Flakes only see files tracked by git. Untracked flake.nix in a git repo:
git init && nix flake showerror: Path 'flake.nix' in the repository "/tmp/app" is not tracked by Git.
To make it visible to Nix, run:
git -C "/tmp/app" add -N "flake.nix"The fix is in the message. The rule exists because Nix copies the source tree into the store, and "tracked by git" is the definition of "part of the project". It also means .gitignored files are invisible to builds. A directory that is not a git repo at all works fine; the rule only applies inside repos.
First entry
git add flake.nix
nix developThe first run fetches nixpkgs metadata, writes flake.lock, downloads the three closures (Node is the big one, allow a few minutes on your line), and drops you into bash with the hook output:
node v22.23.3 | pnpm 12.3.4Inside, PATH starts with the store paths:
nix develop -c sh -c 'echo $PATH' | tr ':' '\n' | head -3/nix/store/qshvhyv92g772jjsm2zrgzxc4ai4iqfx-nodejs-22.23.3/bin
/nix/store/vlics4j96033dphx4kdfax0y2wjc84gm-pnpm-12.3.4/bin
/nix/store/90ac0im8imxwzn0cnmxr7gqhy1fzmnk4-biome-2.5.14/bin-c runs one command instead of an interactive shell. nix develop -c pnpm install is how CI uses it.
nix develop starts bash, not fish, because shellHook is bash and the environment is set up by bash. For fish inside: nix develop -c fish. direnv removes this problem entirely.
flake.lock
Generated on first use. Pins nixpkgs to a commit:
"rev": "b6c8664de9b6cc07fe5666a29f91884ba81197c4"Commit it. It is the pnpm-lock.yaml of your toolchain. Nix 2.35 stages it automatically with git add -N; git status shows A flake.lock. Until you commit, every command warns Git tree is dirty. Harmless.
Update to current nixpkgs: nix flake update. Inspect: nix flake metadata. Validate without entering: nix flake check:
checking derivation devShells.x86_64-linux.default...
all checks passed!Replacing mise
Keep both for now. The mental model: mise is per-user and global in scope, with per-directory overrides. A dev shell is per-project only and does nothing outside nix develop. Once a project has a flake, delete its .tool-versions or mise.toml. Leave mise managing tools you use everywhere until you cover those with a profile or home-manager.
Try it
Create the flake above in a fresh directory, enter it once with -c, and confirm the versions. Expect downloads.
nix develop -c sh -c 'node --version; pnpm --version; biome --version'node v22.23.3 | pnpm 12.3.4
v22.23.3
12.3.4
Version: 2.5.14The first line is the shellHook; it runs even with -c.
Exercise
Add an environment variable DATABASE_URL=file:local.db to the shell so echo $DATABASE_URL works inside nix develop, without using shellHook.
Solution
mkShell passes unknown attributes through as environment variables:
{
description = "SvelteKit dev shell";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.${system}.default = pkgs.mkShell {
packages = [ pkgs.nodejs_22 pkgs.pnpm pkgs.biome ];
DATABASE_URL = "file:local.db";
};
};
}nix develop -c sh -c 'echo $DATABASE_URL'file:local.dbAny NAME = "string"; attribute becomes $NAME. This is how mkShell and mkDerivation both work.
Trap
Editing flake.nix to import ./shell.nix, creating the file, running nix develop, and getting:
error: Path 'shell.nix' in the repository "/tmp/app" is not tracked by Git.Nix 2.35 names the file. Older versions said path '/nix/store/...-source/shell.nix' does not exist, with a store path instead of your repo path; that is the same problem in disguise. Either way: git add shell.nix and retry. Every new file a flake touches needs git add before Nix can see it.