Skip to content

Build a Searchable Blog Backend with MySQL and TypeScript

4 min readMySQLDatabasesSQL

A blog backend needs more than a table of posts. Authors and posts need referential integrity, seed operations should not stop halfway through, and search needs an index rather than a scan through every article body.

This tutorial builds that path without turning into a tour of every MySQL feature.

Who this is for: TypeScript developers building a content application or evaluating MySQL for an existing web stack.

Outcome: One runnable script with related tables, transactional seed data, a joined query, and built-in full-text search.

Time: About 20 minutes, plus the first MySQL binary download.

Prerequisites: Node.js 20 or newer and pnpm. No Docker or Cloud account is required.

Start MySQL locally

Install the Layerbase CLI, then create a MySQL instance:

bash
npm i -g layerbase
lbase create mysql-blog -e mysql --start
lbase url mysql-blog

The last command prints the actual connection string. The database name and port may vary:

text
mysql://root@127.0.0.1:3306/mysql_blog

Copy the returned URL instead of assuming port 3306.

Create the TypeScript project:

bash
mkdir mysql-blog-backend
cd mysql-blog-backend
pnpm init
pnpm add mysql2
pnpm add -D tsx typescript

Create blog.ts:

typescript
import mysql, {
  type ResultSetHeader,
  type RowDataPacket,
} from 'mysql2/promise'

type AuthorRow = RowDataPacket & {
  id: number
  name: string
}

type PostRow = RowDataPacket & {
  title: string
  author: string
}

type SearchRow = RowDataPacket & {
  title: string
  relevance: number
}

const rawUrl =
  process.env.MYSQL_URL ?? 'mysql://root@127.0.0.1:3306/test'
const connectionUrl = new URL(rawUrl)
const isLocal = ['127.0.0.1', 'localhost'].includes(
  connectionUrl.hostname,
)

const db = await mysql.createConnection({
  host: connectionUrl.hostname,
  port: Number(connectionUrl.port || 3306),
  user: decodeURIComponent(connectionUrl.username),
  password: decodeURIComponent(connectionUrl.password),
  database: connectionUrl.pathname.slice(1) || 'test',
  ssl: isLocal ? undefined : { rejectUnauthorized: true },
})

// These names are reserved for this tutorial and are replaced on each run.
await db.execute('DROP TABLE IF EXISTS tutorial_posts')
await db.execute('DROP TABLE IF EXISTS tutorial_authors')

await db.execute(`
  CREATE TABLE tutorial_authors (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE
  )
`)

await db.execute(`
  CREATE TABLE tutorial_posts (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    author_id INT UNSIGNED NOT NULL,
    title VARCHAR(255) NOT NULL,
    body TEXT NOT NULL,
    status ENUM('draft', 'published') NOT NULL DEFAULT 'draft',
    published_at DATETIME NULL,
    CONSTRAINT fk_tutorial_post_author
      FOREIGN KEY (author_id)
      REFERENCES tutorial_authors(id),
    INDEX idx_tutorial_author_status (author_id, status),
    FULLTEXT INDEX ft_tutorial_title_body (title, body)
  )
`)

const authors = [
  { name: 'Alice Chen', email: 'alice@example.com' },
  { name: 'Marcus Rivera', email: 'marcus@example.com' },
  { name: 'Priya Sharma', email: 'priya@example.com' },
]

await db.beginTransaction()

try {
  const authorIds = new Map<string, number>()

  for (const author of authors) {
    const [result] = await db.execute<ResultSetHeader>(
      `
        INSERT INTO tutorial_authors (name, email)
        VALUES (?, ?)
      `,
      [author.name, author.email],
    )
    authorIds.set(author.name, result.insertId)
  }

  const posts = [
    {
      author: 'Alice Chen',
      title: 'Choose the Right Database Index',
      body: 'A practical guide to B-tree indexes, query plans, and avoiding unnecessary database indexes.',
      status: 'published',
      publishedAt: '2026-07-10 09:00:00',
    },
    {
      author: 'Alice Chen',
      title: 'Connection Pooling in Node.js',
      body: 'Reuse MySQL connections without opening a new connection for every request.',
      status: 'published',
      publishedAt: '2026-07-15 09:00:00',
    },
    {
      author: 'Marcus Rivera',
      title: 'A Draft About Background Jobs',
      body: 'Notes for a future article about workers and retry policies.',
      status: 'draft',
      publishedAt: null,
    },
    {
      author: 'Priya Sharma',
      title: 'Debug Slow SQL Queries',
      body: 'Use query plans and indexes to find database work that scans too many rows.',
      status: 'published',
      publishedAt: '2026-07-20 09:00:00',
    },
    {
      author: 'Priya Sharma',
      title: 'Use JSON Columns Carefully',
      body: 'Keep frequently joined and filtered values in regular columns, not opaque metadata.',
      status: 'published',
      publishedAt: '2026-07-18 09:00:00',
    },
  ] as const

  for (const post of posts) {
    await db.execute(
      `
        INSERT INTO tutorial_posts
          (author_id, title, body, status, published_at)
        VALUES (?, ?, ?, ?, ?)
      `,
      [
        authorIds.get(post.author),
        post.title,
        post.body,
        post.status,
        post.publishedAt,
      ],
    )
  }

  await db.commit()
  console.log(`Inserted ${authors.length} authors and ${posts.length} posts`)
} catch (error) {
  await db.rollback()
  throw error
}

const [published] = await db.execute<PostRow[]>(`
  SELECT
    p.title,
    a.name AS author
  FROM tutorial_posts p
  JOIN tutorial_authors a ON a.id = p.author_id
  WHERE p.status = 'published'
  ORDER BY p.published_at DESC, p.id
`)

console.log('\nPublished posts:')
for (const post of published) {
  console.log(`  ${post.title} by ${post.author}`)
}

const searchTerm = 'database indexes'
const [searchResults] = await db.execute<SearchRow[]>(
  `
    SELECT
      title,
      MATCH(title, body)
        AGAINST (? IN NATURAL LANGUAGE MODE) AS relevance
    FROM tutorial_posts
    WHERE status = 'published'
      AND MATCH(title, body)
        AGAINST (? IN NATURAL LANGUAGE MODE)
    ORDER BY relevance DESC, title
  `,
  [searchTerm, searchTerm],
)

console.log(`\nSearch results for "${searchTerm}":`)
for (const result of searchResults) {
  console.log(`  ${result.title}`)
}

await db.end()

This script drops and recreates only tutorial_authors and tutorial_posts. Do not reuse those table names for application data.

Run it with the URL returned by the CLI:

bash
MYSQL_URL="$(lbase url mysql-blog)" pnpm tsx blog.ts

Expected output:

text
Inserted 3 authors and 5 posts

Published posts:
  Debug Slow SQL Queries by Priya Sharma
  Use JSON Columns Carefully by Priya Sharma
  Connection Pooling in Node.js by Alice Chen
  Choose the Right Database Index by Alice Chen

Search results for "database indexes":
  Choose the Right Database Index
  Debug Slow SQL Queries

The foreign key prevents a post from referencing an author that does not exist. The transaction prevents a failed seed operation from leaving only some authors or posts inserted.

The full-text index is created with the table. MATCH ... AGAINST uses that index to rank matching rows. MySQL also applies parser rules such as stopword handling and minimum token length. Do not assume language stemming, typo tolerance, or semantic matching without testing the selected parser and version.

Design for repeatable changes

The tutorial rebuilds two disposable tables, but a production deployment should use versioned migrations.

For a safe schema change:

  1. Make the new code compatible with the old and new schema when possible.
  2. Add new columns or indexes before switching reads.
  3. Backfill in bounded batches and record progress.
  4. Verify row counts, constraints, and important query plans.
  5. Remove the old path only after the new version has handled production traffic.

Large index builds and table changes can hold locks or consume significant I/O. Test them against production-like data and understand the online DDL behavior of the exact MySQL version.

Move the proven workflow to Layerbase Cloud

After the local result is correct, create MySQL on Layerbase Cloud. Copy the host, allocated port, username, password, and database from Quick Connect:

bash
MYSQL_URL="mysql://user:password@your-host.cloud.layerbase.dev:PORT/database" \
pnpm tsx blog.ts

Each Cloud MySQL database has its own allocated TLS port. There is no shared port 3306, so do not replace PORT with a guessed value. The script enables certificate verification for non-local hosts.

As verified on July 23, 2026, MySQL requires the $5 per month Solo plan. Solo includes one database, 10 GB of storage, an always-on resource pool, and daily backups with seven-day retention. Pinning the database to that pool is what keeps it awake.

A hibernated MySQL database does not wake when a client connects. Start it from the dashboard before connecting, or pin it to the always-on pool. Check current pricing and always-on documentation before purchasing because plan details can change.

Plan migration and rollback together

For an existing MySQL application:

  • Take a consistent logical backup such as mysqldump --single-transaction.
  • Restore into a separate target and run schema, row-count, and sample-data checks.
  • Run the application's important reads and writes against the target.
  • Freeze writes or account for the final write delta during cutover.
  • Switch the connection string only after verification.
  • Keep the source available until the rollback window closes.

Rollback becomes harder after the target accepts writes. Decide whether to reverse-copy those writes, dual-write temporarily, or accept a defined recovery-point loss before cutover.

The PlanetScale to MySQL migration guide shows a concrete copy and driver-change workflow. If MariaDB is also an option, read MySQL versus MariaDB before choosing.

When MySQL is the wrong fit

MySQL is a strong choice when the application needs relational constraints and fits the MySQL client and operations ecosystem. It is usually the wrong first choice when:

  • The application depends on PostgreSQL-specific extensions or data types.
  • The data is an analytical file workflow better served by DuckDB.
  • A file-based SQLite database meets the concurrency and deployment needs.
  • Search requires typo tolerance, faceting, or semantic retrieval beyond MySQL full-text search.
  • The team is choosing it from a generic performance claim rather than measured workload needs.

The Layerbase CLI is the fastest local path. Layerbase Desktop provides the same workflow in a GUI on macOS, Windows, and Linux. Move to Cloud after the schema, search behavior, migration, and rollback path are verified.