Tutorials Ultimate Web Development Series Chapter 20

JSX, TSX, and npm Scripts — File Types and What `npm run` Actually Does

WebChapter 20 of the Ultimate Web Development Series30 minMay 27, 2026Beginner

You've now seen file extensions like .jsx and .tsx in screenshots, repo trees, and import paths, and command lines like npm run lint, npm run typecheck, npm run build. Every modern JavaScript project has these and nobody ever sits a beginner down to explain what they actually are. This chapter does.

By the end you'll know what makes a .jsx file different from a .js file (and .tsx from .ts), what an .mdx file is, what TypeScript is layered on top of JavaScript, how package.json scripts work, what each of the standard lint, typecheck, test, build, dev scripts does, and how they all plug into the CI pipeline from Ch 15-17.

.js, .jsx, .ts, .tsx — Four File Extensions That Matter

Open any modern web project. The src/ folder is a soup of files ending in .js, .jsx, .ts, and .tsx. The extensions look almost identical but they tell tools four very different things.

ExtensionContainsType-checked?Where you'll see it
.jsPlain JavaScriptNoBackend code (Workers, Node), small scripts, libraries that don't use React.
.jsxJavaScript + JSX syntaxNoReact components written in plain JS.
.tsTypeScript (no JSX)YesBackend code with types, utility functions, anything that's typed but not a React view.
.tsxTypeScript + JSX syntaxYesReact components written in TypeScript. The dominant choice for new frontend code in 2026.

So two axes: JSX or not, typed or not. Four combinations, four extensions.

Loading diagram…

Figure 1 — The four extensions are two independent additions to plain JavaScript: "add JSX syntax" and "add types". .tsx has both, .js has neither, the other two have one of each.

What Is JSX?

Until JSX came along (React, 2013), if you wanted to build a piece of UI in JavaScript you wrote something like this:

const button = document.createElement("button");
button.className = "primary";
button.textContent = "Subscribe";
button.addEventListener("click", handleClick);
document.body.appendChild(button);

That works but it's brutal to read once a UI gets nested. JSX is a syntax extension that lets you write HTML-looking tags directly inside JavaScript:

const button = <button className="primary" onClick={handleClick}>Subscribe</button>;

Same thing. Same DOM result. Hugely more readable.

The trick: JSX is not HTML, and the browser cannot run it directly. A build step (Babel, esbuild, swc, or whatever your framework uses) transforms each tag into a regular JavaScript function call before the browser ever sees it. The compiled output looks roughly like:

const button = React.createElement(
  "button",
  { className: "primary", onClick: handleClick },
  "Subscribe"
);

So JSX is just sugar — pretty syntax for createElement calls. That's all it is.

Two HTML-like rules that surprise beginners

Because JSX is JS-with-HTML-syntax, not HTML, two things differ:

  1. class is className. class is a reserved JavaScript keyword. JSX picked className instead. Same goes for forhtmlFor on labels.
  2. All attributes are camelCase. onclick (HTML) → onClick (JSX). tabindextabIndex. stroke-widthstrokeWidth.

That's the entire learning curve for the syntax part of JSX.

Why JSX needs its own file extension

A .js file can't have JSX in it because most JavaScript parsers will choke on <div> as soon as they see it — to them it looks like a comparison operator. The convention .jsx (or .tsx) tells your toolchain "this file contains JSX, configure the parser accordingly".

In practice many projects today put JSX in .js files anyway and configure their build to allow it everywhere — Next.js does this. The extension is still useful as a hint to your editor and as documentation. New projects should default to .jsx/.tsx for any file containing JSX.

What Is TypeScript?

TypeScript is JavaScript + a type system. You write almost the same code, but you annotate values with their types, and a tool called tsc checks at compile time that the types line up.

Here's a plain .js function:

function greet(name) {
  return "Hello, " + name.toUpperCase();
}
 
greet(42);          // runs, throws at runtime: "name.toUpperCase is not a function"

The same in .ts:

function greet(name: string): string {
  return "Hello, " + name.toUpperCase();
}
 
greet(42);
//    ^^ Argument of type 'number' is not assignable to parameter of type 'string'.

The TypeScript compiler catches the bug before you run the code. That's the entire pitch — a whole class of bugs ("undefined is not a function", "cannot read property X of undefined") becomes impossible to ship if the types are right.

You don't run TypeScript directly. The compiler either:

Either way, the runtime sees regular JavaScript with no types. TypeScript is a development-time check; the browser still runs JS.

TypeScript in 60 seconds of syntax

// Type annotations
const count: number = 5;
const name: string = "Alice";
const active: boolean = true;
 
// Function signatures
function double(n: number): number {
  return n * 2;
}
 
// Objects and interfaces
interface User {
  id: number;
  email: string;
  premium?: boolean;       // ? = optional
}
 
const u: User = { id: 1, email: "a@b.co" };
 
// Union types
function format(value: string | number): string {
  return String(value);
}
 
// Inference — you don't always have to annotate
const items = [1, 2, 3];   // TS infers number[]

That covers ~80% of the TS you'll write day to day. The other 20% (generics, conditional types, infer) is rabbit-hole territory you can ignore until you need it.

TSX = TypeScript + JSX

.tsx is the most common file in a modern frontend codebase. It's a TypeScript file that's allowed to contain JSX. A React component in .tsx:

interface Props {
  label: string;
  onClick: () => void;
}
 
export function PrimaryButton({ label, onClick }: Props) {
  return (
    <button className="primary" onClick={onClick}>
      {label}
    </button>
  );
}

The Props interface declares what the component accepts. If somebody calls <PrimaryButton label={42} />, the TypeScript compiler errors immediately — 42 isn't a string.

That single guarantee is why most teams have moved to .tsx for new components.

What About .mdx?

There's a fifth extension you'll bump into the moment you look at any modern docs site, blog, or tutorial codebase: .mdx. The chapter you're reading right now is an .mdx file.

MDX = Markdown + JSX. It's a file format that lets you write regular Markdown (#, **bold**, lists, links, code fences) and drop React components directly into the prose, on the same line, no escaping required.

A minimal .mdx file looks like this:

# My First MDX Page
 
Regular Markdown works as you'd expect. **Bold**, *italic*,
[links](https://example.com), and `inline code`.
 
But you can also import a React component and use it:
 
import { Callout } from "@/components/Callout";
 
<Callout variant="info" title="Hey!">
  This is a real component rendered inside the Markdown.
</Callout>
 
And the code fences still highlight:
 
```js
console.log("hello from markdown");
 
The build step (Next.js's MDX loader, `next-mdx-remote`, Astro's content collections, etc) parses the file as Markdown for the prose parts and as JSX for any tags, then renders the whole thing as a React tree.
 
### Why MDX is everywhere
 
Before MDX, you had two bad choices for technical writing:
 
1. **Pure Markdown** — readable but boring. Can't embed a live chart, an interactive demo, or a custom callout box without writing raw HTML and losing the simplicity.
2. **Pure JSX** — you can embed anything, but every paragraph is a `<p>{"..."}</p>` and writing prose is misery.
 
MDX is the obvious third choice: write prose like Markdown, drop in a `<Mermaid>` or `<Callout>` or `<DemoApp />` exactly where it fits, no context switching.
 
The result is the format docs people actually want to write *and* read:
 
- **Docs sites**: Docusaurus, Nextra, Astro Starlight, and Mintlify all use MDX.
- **Blog engines**: Next.js with `@next/mdx`, Astro, Gatsby — all default to MDX.
- **Component libraries**: Storybook's docs format (`.mdx` for stories with prose).
- **This site**: every `.mdx` under `website-next/src/content/` is rendered by `next-mdx-remote`, with custom components like `<Callout>` and `<Mermaid>` registered globally so chapters can use them without imports.
 
### The four-extension table, now five
 
The crossover table from earlier extends one more row:
 
<table>
  <thead>
    <tr><th>Extension</th><th>Contains</th><th>Rendered as</th></tr>
  </thead>
  <tbody>
    <tr><td><code>.js</code></td><td>Plain JavaScript</td><td>Runs as JS</td></tr>
    <tr><td><code>.jsx</code></td><td>JS + JSX</td><td>Compiled to JS, runs as React</td></tr>
    <tr><td><code>.ts</code></td><td>TypeScript</td><td>Type-checked, then compiled to JS</td></tr>
    <tr><td><code>.tsx</code></td><td>TS + JSX</td><td>Type-checked, then compiled to React</td></tr>
    <tr><td><code>.mdx</code></td><td><strong>Markdown + JSX</strong></td><td>Parsed as Markdown, compiled to a React component tree</td></tr>
  </tbody>
</table>
 
So `.mdx` is the "content authoring" extension — same React/JSX runtime as `.tsx`, but the file is mostly prose with components sprinkled in instead of mostly code with HTML-like tags sprinkled in.
 
### Frontmatter — the YAML on top
 
Most MDX files start with a **frontmatter** block delimited by `---`:
 
```mdx
---
title: "My First MDX Page"
description: "A short summary used for SEO"
publishedDate: "May 27, 2026"
chapter: 1
---
 
# My First MDX Page
 
The actual content starts here…

That ----fenced YAML on top is metadata. A reader of the file (typically gray-matter, the npm package every MDX-driven site uses) splits it from the body and gives you both halves:

import matter from "gray-matter";
 
const { data, content } = matter(raw);
// data    = { title: "...", chapter: 1, ... }
// content = "# My First MDX Page\n\nThe actual content..."

This is exactly how this site's src/lib/articles.ts reads chapters: each .mdx file has a frontmatter block with title, chapter, prevSlug, nextSlug, etc., and the body is the prose you're reading.

Things to know that bite beginners

A handful of MDX gotchas:

package.json Scripts — How npm run X Actually Works

Open any project. There's a package.json at the root with a scripts section that looks like this:

{
  "name": "my-app",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "lint": "eslint .",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "format": "prettier --write ."
  }
}

When you run npm run dev, npm looks up dev in that scripts map and runs the right-hand side as a shell command. That's all npm run does — it's an alias system for shell commands.

Two special shortcuts:

The reason every project has a scripts block (instead of putting commands in a README): contributors don't have to know the actual command. They just run npm test and the project decides what that means. Switch from Jest to Vitest? Edit one line in package.json; every contributor's npm test keeps working.

Why npx is everywhere too

You'll see npx eslint, npx tsc, npx vitest. npx runs a binary from your project's node_modules (or downloads it temporarily if it's not installed). Inside an npm script ("lint": "eslint ."), npx is implicit — npm puts node_modules/.bin/ on the PATH automatically. Outside scripts, in a terminal, you need npx to find them.

Loading diagram…

Figure 2 — Inside npm run lint. npm is a thin wrapper that looks the name up in package.json, runs the resolved command with node_modules/.bin in the PATH, and pipes the output back to your terminal.

The Standard Scripts Every Project Has

Here's what each of the canonical scripts does, with the typical right-hand side.

npm run dev

"dev": "next dev"     // or vite, or tsx watch src/index.ts

Starts a local development server with hot reload — change a file, the browser updates instantly. This is the script you run while writing code; you never ship its output. Usually serves on http://localhost:3000.

npm run build

"build": "next build"     // or vite build, or tsc -b

Produces the production output. For a Next.js or Vite app, this is the minified, tree-shaken bundle you'd actually deploy. Different from dev: no source maps inline, no debug helpers, all optimisations on. Slow (seconds to minutes) but only run during release/CI, not while coding.

npm run lint

"lint": "eslint ."

Runs ESLint over the project (covered in Ch 16). Exits non-zero if any error-level rule is violated. CI uses this to block PRs with lint errors. Locally, you mostly let your editor do this for you on every save — but npm run lint is the source of truth.

npm run typecheck

"typecheck": "tsc --noEmit"

Runs the TypeScript compiler in check-only mode — --noEmit says "don't write any output files, just tell me if the types are valid". This is your second-most-important CI gate after tests.

Why --noEmit? In a modern Next.js or Vite app, the bundler (not tsc) is what compiles TS into JS. You use tsc only as a type-checker, not a compiler. --noEmit is the canonical "I want the type-check, skip the output". Older library projects might use plain tsc (without --noEmit) to produce the .js files they publish.

Type errors show up like this:

src/lib/articles.ts:34:7 - error TS2322: Type 'undefined' is not
  assignable to type 'string'.
 
34       title: data.title,
         ~~~~~
 
Found 1 error in src/lib/articles.ts:34

CI runs npm run typecheck on every PR. If a type error sneaks in (someone passed a number where a string was expected), the PR turns red and can't merge until it's fixed.

npm test

"test": "vitest run"

Runs the test suite once and exits (covered in Ch 17). vitest run is the one-shot mode; vitest alone (or npm run test:watch) keeps watching files. CI always uses the one-shot.

npm run format / npm run format:check

"format": "prettier --write .",
"format:check": "prettier --check ."

The --write version rewrites every file with Prettier. The --check version just verifies they're already formatted; that's the one CI runs (you don't want CI silently rewriting files).

Putting It All Together — The Canonical CI Block

Recall the CI workflow from Ch 15. With the scripts above, the CI's steps: becomes a perfectly readable checklist:

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm run lint           # ← Ch 16 (ESLint)
      - run: npm run typecheck      # ← this chapter (tsc --noEmit)
      - run: npm run format:check   # ← Ch 16 (Prettier)
      - run: npm test               # ← Ch 17 (Vitest)
      - run: npm run build          # ← bundler — must succeed

Every step is just an npm run X invocation that runs the same script you'd run locally. CI isn't doing anything magical; it's running your scripts on a fresh VM.

Loading diagram…

Figure 3 — Five named gates. Each is one npm run line in CI. Each is the same script you'd run locally. A red signal anywhere greys out the merge button.

Running Scripts in Sequence or Parallel

A trick worth knowing: scripts can call other scripts.

{
  "scripts": {
    "lint": "eslint .",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "check": "npm run lint && npm run typecheck && npm test"
  }
}

Now npm run check runs lint, then typecheck, then tests, stopping at the first failure. Useful for a single "before-push" command. The && is shell — works on macOS/Linux/Windows-with-Git-Bash.

For parallel execution, install npm-run-all (or concurrently):

{
  "scripts": {
    "check": "npm-run-all --parallel lint typecheck test"
  }
}

Three checks run simultaneously, completes when the slowest finishes. Useful when scripts don't share resources and you want the wall-clock win.

package.json Conventions to Know

A few small things that confuse newcomers:

Mental Model — Three Sentences

  1. .jsx is JS with JSX syntax; .tsx is TS with JSX syntax — the four extensions (.js, .jsx, .ts, .tsx) crossover two independent additions: JSX and types.
  2. npm run <name> is just an alias system: it looks <name> up in the scripts map of package.json and runs the shell command on the right.
  3. The canonical CI pipeline is five npm scripts in a rowlint, typecheck, format:check, test, build — each one a gate that turns the PR red if it fails.

Try It Yourself (10 Minutes)

  1. In any TypeScript project (or npm create vite@latest my-app -- --template react-ts to make a fresh one), open package.json and read the scripts section.
  2. Run each script in turn. Read the output. Notice they all behave identically to running the underlying tool directly with npx.
  3. Open one .tsx component. Change a string prop's value to a number. Save. Run npm run typecheck. Watch the type error appear.
  4. Fix the type. Run npm run typecheck again — clean.
  5. Add a "check": "npm run lint && npm run typecheck && npm test" script and run npm run check. That's now your single "before pushing" command.

From here, every project's package.json is readable at a glance. The names change a little (dev, start, serve all mean the dev server); the pattern doesn't.

Where This Lands in the Series

Chs 15-19 gave you the operational layer (CI, lint, tests, PRs, review). This chapter filled in the missing vocabulary — what the file extensions and CI commands literally mean. One operational question closes the track first: Ch 21 answers what all this CI automation actually costs, the free alternatives (including Cloudflare's own push-to-deploy), and how to get the same safety net locally for $0. Then Part 3 picks up with why the .tsx/.jsx world even exists: what React solved that vanilla JS couldn't, and why almost every team in 2026 builds against a framework.

Ch 19: Code ReviewCh 21: Do You Actually Need GitHub Actions? Cost & Free Alternatives
Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.Astro + Next.jsAstro & Next.js SeriesStatic and hybrid web app patterns with Astro, Next.js, MDX, dynamic routes, and Cloudflare deploys.CloudflareCloudflare Feature FocusFocused Cloudflare tutorials for Workers, R2, Stream, Durable Objects, and edge deployment.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access