Skip to main content

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

All examples in this guide work on VillageSQL. Install Now →
Next.js App Router applications talk to MySQL from three different places: Server Components, Server Actions, and Route Handlers. This guide connects to MySQL from all three using the raw mysql2 driver and a single shared connection pool, with no ORM in between. Because VillageSQL is a drop-in replacement for MySQL, every statement here runs unchanged against it.

Setting Up the Database

Create a dedicated database and application user rather than connecting as root:
Create a table to work with:
Scaffold the app and install the driver:
Store the connection details in .env.local, not in source:

The Connection Pool

Every place in the app that talks to MySQL imports the same pool from one module, src/lib/db.ts:
This is the same pattern used to cache a Prisma or Drizzle client across hot reloads, applied directly to a mysql2 pool. connectionLimit, waitForConnections, and queueLimit are explained in MySQL Connection Pooling; this guide focuses on what changes when the pool lives inside a Next.js process. The globalThis cache is not optional in dev mode. A test on this app proves why: running npm run dev with the cache in place and saving src/lib/db.ts four times in a row left exactly one nextjs_app connection open the whole time, checked with:
Removing the globalThis cache and repeating the same four saves against a fresh next dev process grew that count to five. Each reload re-ran the module top level, and each run called mysql.createPool() again without ever closing the previous pool. Edit the pool module itself to see this: saving a page that merely imports it does not re-run it, because Turbopack keeps the unchanged module alive across the recompile. In production this does not happen, because next build compiles the app once and next start runs it without reloading modules, but the guard costs nothing in production and prevents a real, reproducible connection leak in development.

Querying from a Server Component

src/app/page.tsx reads from notes directly in a Server Component, with a parameterized query:
Never build the query string by concatenating or interpolating user input. The LIMIT ? placeholder above is filled in by mysql2, not by string concatenation, which is what keeps a value like 20; DROP TABLE notes from doing anything but failing to parse as a number. mysql2 returns a MySQL TIMESTAMP column as a JavaScript Date object, not a string. With the Note interface above typing created_at as Date, rendering it directly as JSX ({note.created_at}) fails npm run build’s type check before the app ever runs:
Without that type annotation, the same mistake surfaces later and worse, as a runtime crash instead of a build-time error: Error: Objects are not valid as a React child (found: [object Date]). Either way, the fix is the same: call .toISOString() (or a formatting library) on the value where it is rendered, rather than treating it as a string. Building the app with npm run build shows that this page has no dependency on request-time data (no cookies(), no headers(), nothing marked dynamic), so Next.js prerenders it as static content at build time:
That means the query in getNotes() runs once, during next build, and the database must be reachable at build time. The Server Action below keeps the page’s content current by revalidating it after each write, rather than by re-running the query on every request.

Writing with a Server Action

src/app/actions.ts performs the insert through the same pool:
The form on the home page passes this function directly as its action:
Submitting this form through a real browser, filling in “Server Action verified end to end” and clicking Add, inserted a new row without a full page navigation, and the row appeared in the rendered list immediately because of revalidatePath("/"). Querying the database directly afterward confirmed the write:
revalidatePath("/") invalidates the cached static render from the previous section, so the next request re-runs getNotes() instead of serving the build-time snapshot. Without this call, a static page’s data cache would never notice the new row.

A JSON Route Handler

Server Components and Server Actions cover pages rendered by Next.js itself. A Route Handler is the piece an ORM-focused guide usually skips, because it is the plain HTTP API surface rather than a page: src/app/api/notes/route.ts responds to a GET request with JSON, for a mobile client, a webhook, or any caller that is not the Next.js frontend.
Running the built app with npm run start (port 3000 by default) and requesting this route returns real rows from notes, not placeholder data, including the row the Server Action inserted above:
Unlike the home page, this route reads request-time data indirectly (it queries the database on every call rather than at build time), so the same build output above marks it ƒ for dynamic rather than for static.

Connection Pool Sizing in Next.js

A Route Handler runs once per incoming request, and under concurrent load several requests can be in flight at the same time. The pool exists so those requests share a bounded number of MySQL connections instead of each request opening its own. Firing 20 concurrent requests at /api/notes against the running app and immediately checking the server:
showed only 4 connections in use, against a pool configured with connectionLimit: 10. Threads_connected counts every connection on the server, not just this app’s, so use the information_schema.processlist query from earlier in this guide when you need the per-user number. The exact number depends on how fast each query completes relative to when the burst lands, so a slower query or a busier server would show a higher count, up to the configured limit. What it confirms either way is the pool queuing and reusing connections across the burst instead of opening 20. Raising connectionLimit raises how many requests can run their queries in parallel before the rest wait in the pool’s internal queue (bounded by queueLimit, or unbounded if queueLimit is 0); it does not change how many requests the app can accept, only how many can be mid-query against MySQL at once. Two things specific to Next.js affect this number in practice:
  • Every server process gets its own pool. A Next.js deployment that runs multiple instances (multiple next start processes, or multiple serverless function instances) multiplies connectionLimit by however many instances are running. Size connectionLimit and MySQL’s max_connections together, not connectionLimit alone.
  • Dev mode can multiply pools within a single process, as shown in the connection pool section above, if the pool is not cached on globalThis. This does not happen in a production build, but it is worth checking for during development, because the symptom (MySQL reporting far more open connections than the app should need) looks identical to a production sizing problem.

Choosing an ORM Instead

This guide uses mysql2 directly because a raw connection pool is the piece every Next.js and MySQL integration shares, regardless of what sits on top of it. If the application would benefit from generated types, migrations, or a query builder, see Next.js and Prisma with MySQL or Next.js and Drizzle with MySQL for MySQL-specific setup, and Using MySQL with ORMs for the settings that matter across ORMs generally.

Frequently Asked Questions

Why does rendering note.created_at fail instead of just printing as text?

mysql2 decodes a MySQL TIMESTAMP or DATETIME column into a JavaScript Date object, and neither TypeScript’s ReactNode type nor React itself can render one directly as JSX. With the column typed as Date, npm run build catches {note.created_at} at type-check time with error TS2322: Type 'Date' is not assignable to type 'ReactNode'. Without that type, the same mistake reaches runtime instead and throws Error: Objects are not valid as a React child (found: [object Date]). Call .toISOString(), .toLocaleString(), or a formatting library on the value before rendering it.

Do I need a connection pool if I only expect a few users?

Yes. Even with light traffic, Next.js can run several requests concurrently (a page load that also calls a Route Handler, two browser tabs, a bot crawling the site), and each MySQL connection is a real thread on the server. A pool with a small connectionLimit, ten is a reasonable default for a small app, costs nothing when idle and prevents a burst of concurrent requests from opening one connection each.

Can I call a Server Action from a Route Handler, or the other way around?

A Route Handler can call the same function a Server Action calls, since both are just asynchronous functions that use the pool. What differs is the calling convention: a Server Action is invoked by a <form action> or by React’s useActionState, and a Route Handler is invoked by an HTTP request. Share the database logic in a plain function and call it from both, rather than having one call the other directly.

Why is the home page static but the API route is not?

Next.js decides this per route, based on whether the route reads request-time data. src/app/page.tsx in this guide calls no runtime API like cookies() or headers(), so next build prerenders it once and serves that snapshot until revalidatePath invalidates it. The Route Handler in this guide has no such caching applied, so it queries the database on every request.

Troubleshooting

See also