Stop reading tutorials. Build things. This page walks through building CLIs, TUIs, HTTP APIs, an AI git commit tool, platform SDKs, npm packages, and Dockerizing TS apps -- all with real code patterns.
Bash
mkdir my-cli && cd my-cli
pnpm init
pnpm add -D typescript @types/node tsup
pnpm add commander chalk # CLI framework + colors
JSON
// package.json
{
"name": "my-cli",
"version": "1.0.0",
"type": "module",
"bin": {
"my-cli": "./dist/index.js"
},
"scripts": {
"build": "tsup src/index.ts --format esm --target node20",
"dev": "tsup src/index.ts --format esm --watch"
}
}
TypeScript
// src/index.ts
#!/usr/bin/env node
import { Command } from "commander";
import chalk from "chalk";
const program = new Command();
program
.name("my-cli")
.description("A tool that does cool stuff")
.version("1.0.0");
program
.command("greet")
.description("Greet someone")
.argument("<name>", "person to greet")
.option("-l, --loud", "shout the greeting")
.action((name, options) => {
let msg = `Hello, ${name}!`;
if (options.loud) msg = msg.toUpperCase();
console.log(chalk.green(msg));
});
program
.command("init")
.description("Initialize a new project")
.option("--template <name>", "template to use", "default")
.action(async (options) => {
console.log(chalk.blue(`Creating project with ${options.template} template...`));
// scaffold files here
});
program.parse();
TypeScript
import { input, select, confirm } from "@inquirer/prompts";
const name = await input({ message: "Project name?" });
const template = await select({
message: "Pick a template",
choices: [
{ name: "Minimal", value: "minimal" },
{ name: "Full", value: "full" },
],
});
const ok = await confirm({ message: "Proceed?" });
#!/usr/bin/env node at the top of your entry file tells the OS to run it with Node. Without it, my-cli won't work when installed globally.
Ink lets you build terminal UIs using React components. State management, hooks, and JSX -- but rendered to the terminal instead of a browser.
Bash
pnpm add ink react
pnpm add -D @types/react
TypeScript
// src/ui.tsx
import React, { useState, useEffect } from "react";
import { render, Text, Box, useInput } from "ink";
function App() {
const [count, setCount] = useState(0);
const [items] = useState(["Build CLI", "Write tests", "Deploy"]);
const [selected, setSelected] = useState(0);
useInput((input, key) => {
if (key.upArrow) setSelected(s => Math.max(0, s - 1));
if (key.downArrow) setSelected(s => Math.min(items.length - 1, s + 1));
if (input === "q") process.exit();
});
return (
<Box flexDirection="column" padding={1}>
<Text bold color="green">My TUI App</Text>
{items.map((item, i) => (
<Text key={i} color={i === selected ? "cyan" : "white"}>
{i === selected ? "❯ " : " "}{item}
</Text>
))}
<Text dimColor>↑↓ navigate, q quit</Text>
</Box>
);
}
render(<App />);
<Text> for text, <Box> for flexbox layout, <Spinner> for loading indicators, <TextInput> for user input. Ink also supports useStdout, useStdin, and custom hooks.
TypeScript
import express from "express";
const app = express();
app.use(express.json());
app.get("/api/users", (req, res) => {
res.json([{ id: 1, name: "Sean" }]);
});
app.post("/api/users", (req, res) => {
const { name, email } = req.body;
res.status(201).json({ id: "new-id", name, email });
});
app.listen(3000);
TypeScript
import Fastify from "fastify";
const app = Fastify({ logger: true });
app.get("/api/users", async (req, reply) => {
return [{ id: 1, name: "Sean" }];
});
// Schema validation built in
app.post("/api/users", {
schema: {
body: {
type: "object",
required: ["name", "email"],
properties: {
name: { type: "string" },
email: { type: "string", format: "email" },
},
},
},
}, async (req, reply) => {
reply.code(201);
return { id: "new-id", ...req.body };
});
app.listen({ port: 3000 });
TypeScript
import { Hono } from "hono";
import { serve } from "@hono/node-server";
const app = new Hono();
app.get("/api/users", (c) => {
return c.json([{ id: 1, name: "Sean" }]);
});
app.post("/api/users", async (c) => {
const body = await c.req.json();
return c.json({ id: "new-id", ...body }, 201);
});
serve(app, { port: 3000 });
Text
Framework Speed Ecosystem Best For
──────────────────────────────────────────────────────
Express Slow Massive Legacy, middleware-heavy apps
Fastify Fast Growing Performance-critical APIs
Hono Fastest Small Edge computing, lightweight APIs
A real CLI tool that reads your git diff, sends it to an AI, and generates a commit message. Here's the architecture.
Text
Flow:
1. Run `git diff --staged` to get changes
2. Send diff to AI API (Anthropic/OpenAI)
3. AI generates a commit message
4. Show to user, let them accept/edit/reject
5. Run `git commit -m "..."`
TypeScript
// src/index.ts
#!/usr/bin/env node
import { execSync } from "node:child_process";
import Anthropic from "@anthropic-ai/sdk";
import { confirm, editor } from "@inquirer/prompts";
const client = new Anthropic();
async function main() {
// 1. Get the staged diff
const diff = execSync("git diff --staged", { encoding: "utf8" });
if (!diff.trim()) {
console.log("No staged changes. Stage files with `git add` first.");
process.exit(1);
}
// 2. Send to AI
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 300,
messages: [{
role: "user",
content: `Generate a concise git commit message for this diff.
Use conventional commit format (feat:, fix:, refactor:, etc.).
One line summary, then optional body.\n\n${diff}`
}],
});
const message = response.content[0].type === "text"
? response.content[0].text : "";
console.log(`\nSuggested commit message:\n${message}\n`);
// 3. Let user accept, edit, or reject
const accept = await confirm({ message: "Use this message?" });
if (accept) {
execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`);
console.log("Committed!");
} else {
const edited = await editor({ message: "Edit the message:", default: message });
execSync(`git commit -m "${edited.replace(/"/g, '\\"')}"`);
console.log("Committed with edited message!");
}
}
main().catch(console.error);
JSON
// package.json
{
"name": "ai-commit",
"bin": { "ai-commit": "./dist/index.js" },
"dependencies": {
"@anthropic-ai/sdk": "^0.30.0",
"@inquirer/prompts": "^5.0.0"
}
}
ANTHROPIC_API_KEY env var, then git add . and run ai-commit. It reads your diff, generates a message, and commits.
An SDK wraps an API so users don't deal with raw HTTP. Here's the pattern.
TypeScript
// src/client.ts
interface ClientConfig {
apiKey: string;
baseUrl?: string;
}
export class MyPlatformClient {
private apiKey: string;
private baseUrl: string;
constructor(config: ClientConfig) {
this.apiKey = config.apiKey;
this.baseUrl = config.baseUrl ?? "https://api.myplatform.com";
}
private async request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: {
"Authorization": `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
...options?.headers,
},
});
if (!res.ok) {
const body = await res.text();
throw new ApiError(res.status, body);
}
return res.json() as Promise<T>;
}
// Resource-specific methods
readonly users = {
list: () => this.request<User[]>("/users"),
get: (id: string) => this.request<User>(`/users/${id}`),
create: (data: CreateUserInput) =>
this.request<User>("/users", {
method: "POST",
body: JSON.stringify(data),
}),
};
}
// Usage:
const client = new MyPlatformClient({ apiKey: "sk-..." });
const users = await client.users.list();
const user = await client.users.create({ name: "Sean" });
JSON
{
"name": "@yourname/my-package",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"files": ["dist"], // only publish dist/
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"prepublishOnly": "npm run build"
}
}
Bash
# Login to npm
npm login
# Check what will be published
npm pack --dry-run
# Publish (public scoped package)
npm publish --access public
# Bump version and publish
npm version patch # 1.0.0 → 1.0.1
npm version minor # 1.0.1 → 1.1.0
npm version major # 1.1.0 → 2.0.0
npm publish
files field is critical. Without it, npm publishes your entire project (including src, tests, configs). Set "files": ["dist"] to only publish the built output. Also add a .npmignore or rely on files as an allowlist.
Dockerfile
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY tsconfig.json ./
COPY src/ ./src/
RUN pnpm build
# Stage 2: Production (no dev deps, no TS source)
FROM node:20-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile --prod
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]
Text
Single stage: ~800MB (includes TS, dev deps, source)
Multi-stage: ~150MB (only JS output + prod deps)
YAML
services:
api:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/mydb
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
docker compose up for databases and external services, but run your TS app locally with tsx watch src/index.ts for fast iteration. Only Dockerize the TS app for production builds and CI.
Text
my-lib/
├── src/
│ ├── index.ts ← entry point, re-exports
│ ├── client.ts
│ └── types.ts
├── tests/
│ └── client.test.ts
├── package.json
├── tsconfig.json
└── tsup.config.ts
Text
my-api/
├── src/
│ ├── index.ts ← server entry
│ ├── routes/
│ │ ├── users.ts
│ │ └── posts.ts
│ ├── middleware/
│ │ ├── auth.ts
│ │ └── validate.ts
│ ├── services/ ← business logic
│ │ └── user-service.ts
│ ├── db/
│ │ ├── client.ts
│ │ └── schema.ts
│ └── types/
│ └── index.ts
├── tests/
├── Dockerfile
├── docker-compose.yml
├── package.json
└── tsconfig.json
Text
my-app/
├── packages/
│ ├── shared/ ← types, utils, validation
│ │ ├── src/
│ │ └── package.json
│ ├── api/ ← backend
│ │ ├── src/
│ │ ├── Dockerfile
│ │ └── package.json
│ └── web/ ← frontend
│ ├── src/
│ └── package.json
├── package.json ← workspaces root
├── pnpm-workspace.yaml
├── turbo.json
└── tsconfig.base.json ← shared TS config