Skip to main content

VillageSQL is a drop-in replacement for MySQL with extensions.

All examples in this guide work on VillageSQL. Install Now →
Drizzle ORM is a TypeScript query builder that generates SQL migrations from a schema file and gives you a typed query API, without an intermediate query language of its own. This guide connects a Next.js App Router application to MySQL through Drizzle and the mysql2 driver, covering schema, migrations, reads from a Server Component, and writes from a Server Action. It assumes an existing app scaffolded with create-next-app --src-dir, which sets up the @/* import alias the code samples use.

Install Drizzle and the MySQL Driver

Drizzle needs the mysql2 driver to talk to MySQL, plus drizzle-kit for generating and running migrations:
drizzle-kit moves quickly and has published pre-release tags (beta, rc) under dist-tags alongside latest. Check npm view drizzle-orm dist-tags and npm view drizzle-kit dist-tags before installing to confirm latest actually points at a stable release rather than a release candidate. Set the connection string in .env:

Create the Database User

Create a dedicated database and a user scoped to it, rather than connecting the application as root:
A connection through the Unix socket is always matched against the 'localhost' host, regardless of which IP-based grants exist; a user created only as 'drizzle_app'@'127.0.0.1' cannot authenticate over the socket. A TCP connection to 127.0.0.1 is matched by reverse DNS lookup, so whether it also satisfies a 'localhost' grant depends on how the server resolves 127.0.0.1, not on a fixed MySQL rule. Grant the host your application actually uses, and don’t rely on 127.0.0.1 and localhost being interchangeable.

Define the Schema

Drizzle’s MySQL schema uses functions from drizzle-orm/mysql-core to describe each column:
src/db/schema.ts
varchar requires an explicit length. MySQL stores VARCHAR with a maximum length baked into the column type, unlike PostgreSQL, so Drizzle has no default to fall back on and needs the value up front.

Configure drizzle-kit

drizzle-kit reads a config file to know where the schema lives and how to reach the database:
drizzle.config.ts
drizzle-kit reads .env itself before your config file runs, so process.env.DATABASE_URL is already populated here with no dotenv import needed. This is specific to drizzle-kit: a plain Node or tsx script has no such loader built in, as the verification script later in this guide shows. Add the migration scripts to package.json:
package.json

Generate and Run the Migration

drizzle-kit generate diffs the schema file against the last generated migration and writes a new SQL file:
drizzle-kit prints the config and schema paths in absolute form; /path/to/your-app above stands in for your project directory. The generated file name includes a random two-word suffix, so yours will differ; only the numeric prefix (0000) is meaningful for ordering. Re-running db:generate with no schema change reports No schema changes, nothing to migrate rather than writing a second file. The migration itself is plain SQL:
drizzle/0000_premium_tiger_shark.sql
Apply it:
drizzle-kit migrate creates a __drizzle_migrations table the first time it runs, to track which migration files have already been applied. Confirm both tables exist:

Create the Database Client

The mysql2 driver connects through a pool, and Drizzle wraps that pool:
src/db/index.ts
The mode: "default" option is specific to the mysql2 driver and has no equivalent on Drizzle’s PostgreSQL or SQLite drivers. Omitting it is a type error, not a silent default.

Read Posts in a Server Component

A Server Component can call db.select() directly during rendering, with no API route in between:
src/app/page.tsx

Insert Posts with a Server Action

A Server Action runs on the server and can be passed directly to a form’s action prop:
src/app/actions.ts
revalidatePath("/") is necessary even on a route that renders on every request. Without it, a Server Action’s response is not guaranteed to include a refreshed copy of the page it was called from.

The Static Rendering Trap

npm run build compiles the application, but it also decides, per route, whether a page can be prerendered once at build time or must be rendered on every request. A Server Component that reads the database with no other dynamic API in the same route (no cookies(), no headers(), no searchParams) gives Next.js nothing that forces per-request rendering, so it prerenders the page once during the build and serves that same HTML to every visitor afterward. This was reproducible: removing export const dynamic = "force-dynamic"; from page.tsx and rebuilding produced a route marked static:
A row inserted directly into the database after that build did not appear when the built application was started with npm run start. The page kept serving the row count and content captured at build time. Adding export const dynamic = "force-dynamic"; to page.tsx and rebuilding changed the route to dynamic:
After that change, a row inserted post-build appeared immediately on the next request, with no rebuild required. Any page that reads from the database and needs to reflect writes made after the build needs export const dynamic = "force-dynamic";, or another Next.js API (like reading cookies()) that has the same effect of forcing per-request rendering.

Verify the Write Path Independently of the ORM

A script that inserts through Drizzle and only checks Drizzle’s own return value does not prove the row reached the database; it proves the driver returned without throwing. Verify with a separate query:
src/db/verify-insert.ts
Run it with tsx, which executes TypeScript directly:
import "dotenv/config" at the top of the script is required here. Next.js loads .env for the application automatically, but a standalone script run with tsx is a plain Node process with no framework wiring, and Node itself does not read .env files. Then confirm the row exists with a query that never went through the application:

Frequently Asked Questions

Does this work the same way with VillageSQL as with MySQL?

Yes. Drizzle’s MySQL dialect and the mysql2 driver speak the standard MySQL wire protocol, and VillageSQL is a drop-in replacement for MySQL with the same protocol and client tools. Nothing in the schema, migration, or query code above is MySQL-version-specific.

Should I use drizzle-kit generate and migrate, or drizzle-kit push?

generate plus migrate writes a SQL migration file you can review and commit before it runs against a database, which is what this guide uses. drizzle-kit push compares the schema file to the live database and applies the difference directly, with no migration file. push is faster for early prototyping but leaves no record of what changed or when, and is not a substitute for migration files once other developers or environments depend on the schema.

Why does the migration folder have a table called __drizzle_migrations?

drizzle-kit migrate creates this table on first run to record which migration files it has already applied, by hash. It is how migrate knows to skip a migration that already ran instead of applying it twice.

Can I call db.insert() directly from a Server Component instead of a Server Action?

A Server Component only runs during rendering; there is no request from the browser to trigger a write in response to a user action. A write path needs a Server Action, a Route Handler, or client-side code calling an API route.

Troubleshooting

See also