Getting Started with MySQL
MySQL is the world's most popular open-source relational database. WordPress, Shopify, GitHub, and countless others run on it. It's fast at read-heavy workloads, simple to operate, and supported by every language, framework, and hosting provider you'll ever touch. If you need a relational database for a web app, MySQL is a safe, proven default.
We'll build a blog platform schema in one TypeScript file: authors, posts with JSON metadata, full-text search, and analytical queries using CTEs. Run everything locally, or provision a managed instance on Layerbase Cloud to skip setup.
Contents
- Create a MySQL Instance
- Set Up the Project
- The Blog Platform Dataset
- Connect and Create Tables
- Insert the Data
- Query with JOINs
- JSON Columns
- Full-Text Search
- Common Table Expressions
- Generated Columns
- MySQL vs. PostgreSQL
- When to Reach for MySQL
- Wrapping Up
Create a MySQL Instance
Local with SpinDB
SpinDB lets you run MySQL locally without Docker or manual installs. (What is SpinDB?)
Install SpinDB globally:
npm i -g spindb # npm
pnpm add -g spindb # pnpmOr run it directly without installing:
npx spindb create mysql1 -e mysql --start # npm
pnpx spindb create mysql1 -e mysql --start # pnpmIf you installed globally, create and start a MySQL instance:
spindb create mysql1 -e mysql --startSpinDB downloads the MySQL binary, configures it, and starts the server. Verify it's running:
spindb url mysql1mysql://root@127.0.0.1:3306Leave the server running.
Layerbase Cloud
Don't want to run anything locally? Layerbase Cloud gives you a managed MySQL instance in a few clicks. Connection details are in the Quick Connect panel.
Cloud instances use TLS, so the connection code is slightly different:
const db = await mysql.createConnection({
host: 'cloud.layerbase.dev',
port: 3306,
user: 'your_user',
password: 'your_password',
database: 'your_database',
ssl: { rejectUnauthorized: true },
})Everything else in this guide works identically whether you're running locally or on Layerbase Cloud. Just swap in your connection details.
Set Up the Project
mkdir mysql-blog-platform && cd mysql-blog-platform
pnpm init
pnpm add mysql2
pnpm add -D tsx typescriptCreate a file called blog.ts. All the code in this post goes into that one file.
The Blog Platform Dataset
Five authors, 10 posts, 15 comments. This kind of content-heavy, read-heavy workload is exactly where MySQL excels:
const authors = [
{ name: 'Alice Chen', email: 'alice@example.com', bio: 'Backend engineer who writes about databases and distributed systems.' },
{ name: 'Marcus Rivera', email: 'marcus@example.com', bio: 'Full-stack developer and open source contributor.' },
{ name: 'Priya Sharma', email: 'priya@example.com', bio: 'DevOps engineer focused on infrastructure automation.' },
{ name: 'James Okafor', email: 'james@example.com', bio: 'Technical writer covering cloud-native architecture.' },
{ name: 'Sara Kim', email: 'sara@example.com', bio: 'Frontend engineer exploring the intersection of design and performance.' },
]
const posts = [
{ author_id: 1, title: 'Understanding Database Indexes', slug: 'understanding-database-indexes', content: 'Database indexes are data structures that speed up read queries at the cost of slower writes. A B-tree index lets the database find rows without scanning the entire table. Every table should have a primary key index, and you should add secondary indexes on columns you frequently filter or sort by.', status: 'published', published_at: '2026-01-15 10:00:00', metadata: { reading_time: 8, category: 'databases', featured: true } },
{ author_id: 1, title: 'Connection Pooling in Node.js', slug: 'connection-pooling-nodejs', content: 'Opening a new database connection for every query is expensive. Connection pooling keeps a set of idle connections ready to reuse. In Node.js, most database drivers support pooling out of the box. Configure the pool size based on your expected concurrency and database limits.', status: 'published', published_at: '2026-01-22 09:00:00', metadata: { reading_time: 5, category: 'backend', featured: false } },
{ author_id: 2, title: 'Getting Started with TypeScript', slug: 'getting-started-typescript', content: 'TypeScript adds static type checking to JavaScript. It catches bugs at compile time that would otherwise appear at runtime. Start by adding a tsconfig.json to your project and renaming your files from .js to .ts. The compiler will guide you through adding type annotations.', status: 'published', published_at: '2026-02-01 11:00:00', metadata: { reading_time: 6, category: 'typescript', featured: true } },
{ author_id: 2, title: 'Building REST APIs with Express', slug: 'building-rest-apis-express', content: 'Express is the most popular Node.js web framework. Define routes, add middleware for authentication and validation, and return JSON responses. Keep your route handlers thin by moving business logic into service modules.', status: 'published', published_at: '2026-02-10 14:00:00', metadata: { reading_time: 10, category: 'backend', featured: false } },
{ author_id: 3, title: 'Docker for Local Development', slug: 'docker-local-development', content: 'Docker containers provide consistent development environments. Write a Dockerfile for your application, use docker-compose to orchestrate multiple services, and mount volumes for live code reloading. Your local setup will match production exactly.', status: 'published', published_at: '2026-02-15 08:00:00', metadata: { reading_time: 7, category: 'devops', featured: false } },
{ author_id: 3, title: 'CI/CD Pipeline Best Practices', slug: 'cicd-pipeline-best-practices', content: 'A good CI/CD pipeline runs tests on every commit, builds artifacts once, and deploys them through staging before production. Keep pipelines fast by caching dependencies and running tests in parallel. Fail early and provide clear error messages.', status: 'published', published_at: '2026-02-20 13:00:00', metadata: { reading_time: 9, category: 'devops', featured: true } },
{ author_id: 4, title: 'Microservices Communication Patterns', slug: 'microservices-communication', content: 'Microservices need reliable communication. Synchronous HTTP calls are simple but create tight coupling. Asynchronous messaging through queues like RabbitMQ or Kafka provides better resilience. Choose based on whether you need an immediate response.', status: 'published', published_at: '2026-03-01 10:00:00', metadata: { reading_time: 12, category: 'architecture', featured: false } },
{ author_id: 4, title: 'Kubernetes Networking Explained', slug: 'kubernetes-networking', content: 'Kubernetes networking connects pods, services, and the outside world. Every pod gets its own IP address. Services provide stable endpoints. Ingress controllers handle external traffic routing. Understanding this model is essential for debugging connectivity issues.', status: 'draft', published_at: null, metadata: { reading_time: 15, category: 'devops', featured: false } },
{ author_id: 5, title: 'Web Performance Optimization', slug: 'web-performance-optimization', content: 'Fast websites convert better. Start by measuring Core Web Vitals. Reduce bundle sizes with code splitting and tree shaking. Lazy load images below the fold. Use a CDN for static assets. Every millisecond of load time matters for user experience.', status: 'published', published_at: '2026-03-05 09:00:00', metadata: { reading_time: 8, category: 'frontend', featured: true } },
{ author_id: 5, title: 'Accessible Component Design', slug: 'accessible-component-design', content: 'Accessibility is not optional. Use semantic HTML elements, add ARIA labels where needed, ensure keyboard navigation works, and test with screen readers. Build accessibility into your component library from day one rather than retrofitting it later.', status: 'published', published_at: '2026-03-10 11:00:00', metadata: { reading_time: 6, category: 'frontend', featured: false } },
]
const comments = [
{ post_id: 1, author_name: 'DevDave', body: 'Great explanation of B-tree indexes. Would love a follow-up on partial indexes.' },
{ post_id: 1, author_name: 'SQLFan', body: 'This helped me understand why my queries were slow. Added an index and got a 10x improvement.' },
{ post_id: 1, author_name: 'NewbieCoder', body: 'Clear and practical. Bookmarked for reference.' },
{ post_id: 2, author_name: 'NodeNinja', body: 'Connection pooling made a huge difference in our production app.' },
{ post_id: 3, author_name: 'JSLearner', body: 'Finally a TypeScript intro that does not assume prior knowledge of static typing.' },
{ post_id: 3, author_name: 'TypeFan', body: 'One tip: enable strict mode in tsconfig from the start. It saves headaches later.' },
{ post_id: 4, author_name: 'APIBuilder', body: 'Good advice on keeping route handlers thin. Service layer pattern is underrated.' },
{ post_id: 5, author_name: 'ContainerKing', body: 'Docker compose changed my local dev workflow completely.' },
{ post_id: 5, author_name: 'DevOpsDaily', body: 'Worth mentioning that volume mounts can be slow on macOS. Use mutagen or similar tools.' },
{ post_id: 6, author_name: 'PipelinePro', body: 'Caching dependencies cut our CI time in half. Great point about parallel test execution too.' },
{ post_id: 6, author_name: 'ShipItFast', body: 'We added a staging gate after reading this. Caught two bugs before they hit production.' },
{ post_id: 7, author_name: 'ArchitectAli', body: 'Event-driven messaging adds complexity but the resilience gains are worth it.' },
{ post_id: 9, author_name: 'PerfNerd', body: 'Code splitting alone cut our initial load time by 40 percent.' },
{ post_id: 9, author_name: 'FrontendFiona', body: 'Lazy loading images was the easiest win. Should be the default for every project.' },
{ post_id: 10, author_name: 'A11yAdvocate', body: 'Thank you for making the point that accessibility is not optional. More developers need to hear this.' },
]Connect and Create Tables
AUTO_INCREMENT handles primary keys, ENUM constrains column values, and mysql2/promise gives us async/await:
import mysql from 'mysql2/promise'
const db = await mysql.createConnection({
host: '127.0.0.1',
port: 3306,
user: 'root',
database: 'test',
})
// Clean up from previous runs
await db.execute('DROP TABLE IF EXISTS comments')
await db.execute('DROP TABLE IF EXISTS posts')
await db.execute('DROP TABLE IF EXISTS authors')
await db.execute(`
CREATE TABLE authors (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
bio TEXT
)
`)
await db.execute(`
CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY,
author_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
content TEXT NOT NULL,
status ENUM('draft', 'published') DEFAULT 'draft',
published_at DATETIME,
metadata JSON,
FOREIGN KEY (author_id) REFERENCES authors(id)
)
`)
await db.execute(`
CREATE TABLE comments (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
author_name VARCHAR(100) NOT NULL,
body TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id)
)
`)
console.log('Created tables: authors, posts, comments')Some MySQL-specific details: AUTO_INCREMENT generates sequential IDs. ENUM('draft', 'published') constrains the status column to those two values at the database level. The JSON type on metadata stores structured data you can query with SQL operators. And foreign keys enforce referential integrity, so you can never have a post pointing to a nonexistent author.
Insert the Data
MySQL's execute method supports parameterized queries with ? placeholders. Insert the data:
for (const author of authors) {
await db.execute(
'INSERT INTO authors (name, email, bio) VALUES (?, ?, ?)',
[author.name, author.email, author.bio],
)
}
console.log(`Inserted ${authors.length} authors`)
for (const post of posts) {
await db.execute(
'INSERT INTO posts (author_id, title, slug, content, status, published_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)',
[post.author_id, post.title, post.slug, post.content, post.status, post.published_at, JSON.stringify(post.metadata)],
)
}
console.log(`Inserted ${posts.length} posts`)
for (const comment of comments) {
await db.execute(
'INSERT INTO comments (post_id, author_name, body) VALUES (?, ?, ?)',
[comment.post_id, comment.author_name, comment.body],
)
}
console.log(`Inserted ${comments.length} comments`)Parameterized queries prevent SQL injection. Never interpolate user input into SQL strings. The ? placeholders handle escaping for you.
Query with JOINs
A small helper to run queries and print results:
async function query<T>(sql: string, label: string, params: unknown[] = []): Promise<T[]> {
const [rows] = await db.execute(sql, params)
const results = rows as T[]
console.log(`\n${label}`)
for (const row of results) {
console.log(' ', row)
}
return results
}Published Posts with Author Names
The bread-and-butter blog query: join posts with their authors and order by publication date.
await query(
`SELECT
p.title,
a.name AS author,
p.status,
p.published_at
FROM posts p
JOIN authors a ON p.author_id = a.id
WHERE p.status = 'published'
ORDER BY p.published_at DESC
LIMIT 5`,
'Latest 5 published posts:',
)Latest 5 published posts:
{ title: 'Accessible Component Design', author: 'Sara Kim', status: 'published', published_at: '2026-03-10 11:00:00' }
{ title: 'Web Performance Optimization', author: 'Sara Kim', status: 'published', published_at: '2026-03-05 09:00:00' }
{ title: 'Microservices Communication Patterns', author: 'James Okafor', status: 'published', published_at: '2026-03-01 10:00:00' }
{ title: 'CI/CD Pipeline Best Practices', author: 'Priya Sharma', status: 'published', published_at: '2026-02-20 13:00:00' }
{ title: 'Docker for Local Development', author: 'Priya Sharma', status: 'published', published_at: '2026-02-15 08:00:00' }Posts with Comment Counts
Join posts with a count of their comments. LEFT JOIN ensures posts with zero comments still appear:
await query(
`SELECT
p.title,
a.name AS author,
COUNT(c.id) AS comment_count
FROM posts p
JOIN authors a ON p.author_id = a.id
LEFT JOIN comments c ON c.post_id = p.id
WHERE p.status = 'published'
GROUP BY p.id, p.title, a.name
ORDER BY comment_count DESC`,
'Posts by comment count:',
)Posts by comment count:
{ title: 'Understanding Database Indexes', author: 'Alice Chen', comment_count: 3 }
{ title: 'Getting Started with TypeScript', author: 'Marcus Rivera', comment_count: 2 }
{ title: 'Docker for Local Development', author: 'Priya Sharma', comment_count: 2 }
{ title: 'CI/CD Pipeline Best Practices', author: 'Priya Sharma', comment_count: 2 }
{ title: 'Web Performance Optimization', author: 'Sara Kim', comment_count: 2 }
{ title: 'Connection Pooling in Node.js', author: 'Alice Chen', comment_count: 1 }
{ title: 'Building REST APIs with Express', author: 'Marcus Rivera', comment_count: 1 }
{ title: 'Microservices Communication Patterns', author: 'James Okafor', comment_count: 1 }
{ title: 'Accessible Component Design', author: 'Sara Kim', comment_count: 1 }JSON Columns
MySQL 8+ has native JSON columns with operators for querying structured data in SQL. -> extracts a JSON value (as JSON), ->> extracts it as plain text:
await query(
`SELECT
title,
metadata->>'$.category' AS category,
metadata->>'$.reading_time' AS reading_time,
metadata->>'$.featured' AS featured
FROM posts
WHERE metadata->>'$.featured' = 'true'
ORDER BY title`,
'Featured posts (from JSON metadata):',
)Featured posts (from JSON metadata):
{ title: 'CI/CD Pipeline Best Practices', category: 'devops', reading_time: '9', featured: 'true' }
{ title: 'Getting Started with TypeScript', category: 'typescript', reading_time: '6', featured: 'true' }
{ title: 'Understanding Database Indexes', category: 'databases', reading_time: '8', featured: 'true' }
{ title: 'Web Performance Optimization', category: 'frontend', reading_time: '8', featured: 'true' }You can also filter by numeric JSON values:
await query(
`SELECT
title,
metadata->>'$.reading_time' AS reading_time
FROM posts
WHERE JSON_EXTRACT(metadata, '$.reading_time') > 8
ORDER BY JSON_EXTRACT(metadata, '$.reading_time') DESC`,
'Long reads (over 8 minutes):',
)Long reads (over 8 minutes):
{ title: 'Kubernetes Networking Explained', reading_time: '15' }
{ title: 'Microservices Communication Patterns', reading_time: '12' }
{ title: 'Building REST APIs with Express', reading_time: '10' }
{ title: 'CI/CD Pipeline Best Practices', reading_time: '9' }JSON columns work well for semi-structured data that varies between rows: metadata, settings, feature flags. For data you filter or join on frequently, regular columns are still faster.
Full-Text Search
MySQL has built-in full-text search, no extensions needed. Add a FULLTEXT index and use MATCH ... AGAINST:
await db.execute(
'ALTER TABLE posts ADD FULLTEXT INDEX ft_posts (title, content)',
)
await query(
`SELECT
title,
MATCH(title, content) AGAINST('database indexes query') AS relevance
FROM posts
WHERE MATCH(title, content) AGAINST('database indexes query')
ORDER BY relevance DESC`,
'Full-text search for "database indexes query":',
)Full-text search for "database indexes query":
{ title: 'Understanding Database Indexes', relevance: 2.43 }
{ title: 'Connection Pooling in Node.js', relevance: 0.31 }The relevance score reflects how well each row matches. MySQL handles stemming and stop words automatically.
For finer control, boolean mode supports + (must include), - (must exclude), and * (wildcard):
await query(
`SELECT title
FROM posts
WHERE MATCH(title, content) AGAINST('+typescript -express' IN BOOLEAN MODE)`,
'Boolean search (+typescript -express):',
)Boolean search (+typescript -express):
{ title: 'Getting Started with TypeScript' }For a blog or documentation site, this is often enough. No need to bolt on a separate search engine.
Common Table Expressions
CTEs make complex queries readable by breaking them into named steps. MySQL 8+ supports them with WITH.
Post with Most Comments
await query(
`WITH comment_counts AS (
SELECT
post_id,
COUNT(*) AS total
FROM comments
GROUP BY post_id
)
SELECT
p.title,
a.name AS author,
cc.total AS comments
FROM comment_counts cc
JOIN posts p ON p.id = cc.post_id
JOIN authors a ON a.id = p.author_id
ORDER BY cc.total DESC
LIMIT 1`,
'Post with most comments:',
)Post with most comments:
{ title: 'Understanding Database Indexes', author: 'Alice Chen', comments: 3 }Authors Ranked by Post Count
await query(
`WITH author_stats AS (
SELECT
author_id,
COUNT(*) AS post_count,
SUM(CASE WHEN status = 'published' THEN 1 ELSE 0 END) AS published_count
FROM posts
GROUP BY author_id
)
SELECT
a.name,
s.post_count,
s.published_count,
RANK() OVER (ORDER BY s.published_count DESC) AS rank_position
FROM author_stats s
JOIN authors a ON a.id = s.author_id
ORDER BY rank_position`,
'Authors ranked by published post count:',
)Authors ranked by published post count:
{ name: 'Alice Chen', post_count: 2, published_count: 2, rank_position: 1 }
{ name: 'Marcus Rivera', post_count: 2, published_count: 2, rank_position: 1 }
{ name: 'Priya Sharma', post_count: 2, published_count: 2, rank_position: 1 }
{ name: 'Sara Kim', post_count: 2, published_count: 2, rank_position: 1 }
{ name: 'James Okafor', post_count: 2, published_count: 1, rank_position: 5 }CTEs really pay off when a query needs the same subresult multiple times. Without them, you'd duplicate the subquery or nest it into something nobody wants to debug.
Generated Columns
MySQL generated columns compute their value automatically from other columns. Useful for derived data you want to query or index without maintaining it by hand:
await db.execute(`
ALTER TABLE posts
ADD COLUMN word_count INT
GENERATED ALWAYS AS (LENGTH(content) - LENGTH(REPLACE(content, ' ', '')) + 1) STORED
`)
await query(
`SELECT
title,
word_count
FROM posts
ORDER BY word_count DESC
LIMIT 5`,
'Posts by word count (generated column):',
)Posts by word count (generated column):
{ title: 'Understanding Database Indexes', word_count: 49 }
{ title: 'Connection Pooling in Node.js', word_count: 44 }
{ title: 'CI/CD Pipeline Best Practices', word_count: 42 }
{ title: 'Web Performance Optimization', word_count: 42 }
{ title: 'Docker for Local Development', word_count: 35 }STORED means the value is computed on write and saved to disk. VIRTUAL computes on every read, using no disk space. Both can be indexed in InnoDB, but STORED is simpler when you plan to sort or filter by the value often.
MySQL vs. PostgreSQL
Both are excellent. Here's my take on the tradeoffs:
MySQL is simpler to operate and faster for read-heavy workloads, particularly straightforward lookups and joins. Replication is easy to set up, and managed MySQL is on every cloud provider.
PostgreSQL has richer data types (arrays, hstore, custom types), more powerful extensions (PostGIS, pg_trgm, pgvector), and stricter SQL standards compliance. If you need advanced querying, custom functions, or specialized extensions, PostgreSQL is the stronger pick.
Both support JSON, full-text search, CTEs, window functions, and stored procedures. For most web apps, either works. Choose based on your team's experience and the ecosystem you're building on.
When to Reach for MySQL
MySQL fits naturally into a lot of common setups:
- Read-heavy web apps: blogs, content sites, e-commerce storefronts. MySQL's read replication and buffer pool make it fast when reads far outnumber writes
- Content management systems: WordPress, Drupal, and most CMS platforms default to MySQL. The ecosystem of tools, plugins, and hosting support is unmatched
- E-commerce platforms: Shopify, Magento, WooCommerce. Transaction support handles orders, inventory, and payments reliably
- Ecosystem-first teams: every ORM, every hosting provider, every monitoring tool supports MySQL. You'll never struggle to find docs, tutorials, or managed hosting
- Teams that value simplicity: backups, replication, and upgrades follow well-documented paths. No surprises
If you need a relational database with massive ecosystem support and you're not relying on PostgreSQL-specific extensions, MySQL is a very solid default.
Wrapping Up
One file covers the core of MySQL development: tables with AUTO_INCREMENT, ENUM, and foreign keys; parameterized inserts; JOINs; JSON columns with ->> operators; full-text search; CTEs with window functions; and generated columns. That same feature set scales from a personal blog to a platform serving millions of readers.
The MySQL documentation covers stored procedures, triggers, partitioning, replication, and the performance schema for query optimization.
Close the connection at the end of your script:
await db.end()To manage your local MySQL instance:
spindb stop mysql1 # Stop the server
spindb start mysql1 # Start it again
spindb list # See all your database instancesSpinDB supports 20+ database engines, so you can run MySQL, PostgreSQL, and Redis side by side from one CLI. There's also Layerbase Desktop if you prefer a GUI on macOS.