on this page
You clone a repo, you want to run it, and somewhere there is a magic incantation. Maybe it is node --experimental-loader ./loader.mjs --watch src/server.ts. Maybe it is four terminals and a specific order. You will not guess it, and the person who wrote it has half-forgotten it too.
The fix has existed the whole time, sitting in a file you already have. It is the scripts block in package.json, and learning to use it well is the difference between a project people can run and a project people give up on.
What the scripts block actually is
Open any package.json and you will find a scripts field. It is a plain object that maps a short name to a shell command:
{
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
}
}
That is the whole idea. Each key is a nickname. Each value is the real command that runs when you call the nickname. Type npm run dev and npm looks up dev in that object, finds astro dev, and runs it for you. (npm is the Node Package Manager, the tool that ships with Node.js and installs your dependencies. It also runs these scripts.)
So instead of remembering astro dev, or worse, some twelve-flag monster, you remember npm run dev. The name stays the same across every project even when the command underneath is completely different. On an Astro site npm run dev runs astro dev. On a plain Node API it might run nodemon server.js. You type the same three words either way, and the project tells you what they mean.
There is one small wrinkle in the wording. Most scripts need the word run: npm run build, npm run lint, npm run whatever. A handful of names are special and npm lets you skip it: npm start and npm test work without run. That is purely a historical convenience, not a sign those scripts are different. If you always type npm run, nothing breaks.
If you prefer pnpm, the same scripts work with pnpm dev or pnpm run dev. The block in package.json does not change. The manager you call it with does.
The standard scripts most projects have
Here is the thing nobody tells beginners: the script names are basically a convention. There is no law that forces a project to call its build script build. But almost everyone does, because predictable names mean you can sit down at an unfamiliar repo and already know which lever to pull.
These are the names you will see again and again, with what each one does and a command that does it for real.
dev starts the development server with hot reload, the mode you live in while coding. Save a file, the page updates, no manual refresh. Depending on the stack it runs astro dev, or next dev, or nodemon server.js for a backend that restarts itself when you edit it.
npm run dev
# under the hood, something like: astro dev
build produces the production build: your source code compiled, bundled, and minified into the files you actually ship. For a static site that is astro build. For a TypeScript app bundled with Vite it is often two commands chained together, tsc && vite build, which type-checks first and only bundles if the types pass.
start runs the already-built app the way production runs it, usually node dist/server.js. This is the one to be careful about, and we will come back to it, because start is for the finished build, not for development. (It is also the script that earns the bare npm start with no run.)
test runs your test suite so you find out what you broke before your users do. With vitest run, which runs every test once and exits. (Plain vitest would sit there watching files, which is great while writing tests and wrong for a one-shot check or CI.)
lint runs format runs prettier --write to reformat your files so nobody argues about spaces in a pull request.
preview serves the production build locally so you can sanity-check the real output before shipping, for example astro preview. This is not the same as dev. The dev server fakes a few things for speed; preview runs the actual built files, which is where you catch the bug that only shows up in production.
typecheck runs the type checker without producing any output files, just a thumbs up or a list of errors. For a TypeScript project that is tsc --noEmit (no-emit meaning “check the types but do not write any JavaScript”). For Astro it is astro check, which checks your .astro files too.
The table to keep nearby
| Script | What it does | Typical command |
|---|---|---|
dev | Dev server with hot reload, your daily driver | astro dev |
build | Compile and bundle for production | tsc && vite build |
start | Run the built app the way prod does | node dist/server.js |
test | Run the test suite once | vitest run |
lint | Catch bugs and style issues | eslint . |
format | Auto-format the code | prettier --write . |
preview | Serve the real build locally | astro preview |
typecheck | Check types, emit nothing | tsc --noEmit |
Same names, every project, different commands underneath. That predictability is the entire point.
Why scripts become the control panel for your app
Think about what these names give you together. A person who has never seen your project opens package.json, reads eight lines, and now knows how to run it, build it, test it, and ship it. Thirty seconds, no Slack message, no tribal knowledge.
It goes further than onboarding. Your README tells people to run npm run dev. Your npm run build and npm run test. Your teammate runs npm run lint before pushing. Everyone is calling the exact same names, which means there is one definition of how the app is operated, and it lives in one file.
That last part matters more than it sounds. When the build command changes, you change it in package.json and every caller, your CI, your docs, your coworkers, picks up the new behavior automatically because they all just say npm run build. The name is a stable handle; the command behind it can evolve without breaking anything that points at it.
Running frontend and backend together
Full-stack apps have a specific pain. You have an API server and a web frontend, and they are two separate processes. The naive instructions read like a recipe: open one terminal, run the API, open a second terminal, run the frontend, keep both alive, and do not close the wrong one. That is a wiki page waiting to happen.
The better move is to run both from a single script. A small tool called
npm install --save-dev concurrently
Then wire it into your scripts:
{
"scripts": {
"dev": "concurrently \"npm:dev:api\" \"npm:dev:web\"",
"dev:api": "nodemon server.js",
"dev:web": "astro dev",
"build": "astro build",
"start": "node dist/server.js"
}
}
Now npm run dev starts the API and the web app together, in one window, with one command. The dev:api and dev:web scripts still exist on their own if you want to run just one, and the npm: shorthand tells concurrently to call those scripts by name.
This is the line between a project a new contributor starts in one command and a project that needs a paragraph of setup instructions nobody reads. One command wins every time.
The frontend and backend will need to find each other, which is where configuration like the API base URL comes in. Those values belong in environment variables, not hardcoded into your scripts. If that is unfamiliar, read Environment variables explained and keep your scripts about running commands, not about secrets and URLs.
Two example setups
For a frontend-only app, the script block is short and that is correct. You do not need scripts you will not run.
{
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"typecheck": "astro check",
"lint": "eslint .",
"format": "prettier --write ."
}
}
For a full-stack app with an dev and a real start for the built server:
{
"scripts": {
"dev": "concurrently \"npm:dev:api\" \"npm:dev:web\"",
"dev:api": "nodemon src/server.ts",
"dev:web": "vite",
"build": "tsc && vite build",
"start": "node dist/server.js",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"format": "prettier --write ."
}
}
Notice that dev and start are different on purpose. dev runs two watch processes for local work. start runs the single compiled server the way
Common mistakes
These are the ones that turn a fine project into an annoying one. None of them are hard to avoid once you have seen them.
Cryptic or missing scripts. If scripts is empty, or every entry is an unlabeled wall of flags, nobody knows how to run the app and they will ask you, repeatedly. Name your scripts the conventional names. The goal is that someone reads the block and understands it without you in the room.
Using the dev server as your production start command. This is the big one. The dev server (astro dev, vite, next dev) is built for fast feedback while you code: unminified, watching files, sometimes wide open to the network. It is not built to serve real traffic, and it can be slower and less safe in production. Production should run build and then start, never dev. If you containerize the app, your Dockerfile should run the built output too. The localhost to production guide walks through why the build and the dev server are different animals, and that distinction is exactly what start exists to respect.
Scripts that only work on one person’s machine. A script that calls a tool you installed globally, or hardcodes /Users/you/..., or assumes a database that only exists on your laptop, works for you and fails for everyone else. Install tools as dev dependencies so they live in node_modules and run for the whole team. A script everyone can run is the whole reason the block exists.
No typecheck or lint script. Your CI can only enforce a command that exists. If there is no lint script, the pipeline cannot run the linter, and broken or untyped code walks straight into main. Add typecheck and lint even on a small project. The day you wire up CI, they are already there to call.
Why this is worth ten minutes
Good scripts are not busywork or polish you add later. They are how a project gets easy to join, easy to deploy, and easy to debug.
Easy to join, because a new contributor reads package.json and starts the whole thing with npm run dev. Easy to deploy, because your host runs npm run build and npm start and gets the production version, not the dev server. Easy to debug, because when something is wrong you have a typecheck, a lint, and a test that everyone runs the same way, so “works on my machine” stops being a mystery.
A real project is one other people can run, and shipping it is mostly about everyone, including the build server, agreeing on how. The scripts block is where that agreement lives. Spend the ten minutes naming yours well, and every future contributor, including the version of you who comes back in six months having forgotten everything, will quietly thank you.