VillageSQL is a drop-in replacement for MySQL with extensions.
All examples in this guide work on VillageSQL. Install Now →
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:.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:
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:
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:
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:
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:
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:
action:
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.
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:
ƒ 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:
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 startprocesses, or multiple serverless function instances) multipliesconnectionLimitby however many instances are running. SizeconnectionLimitand MySQL’smax_connectionstogether, notconnectionLimitalone. - 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 usesmysql2 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 smallconnectionLimit, 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
- Next.js and Prisma with MySQL — the same app built on Prisma instead of a raw driver
- Next.js and Drizzle with MySQL — the same app built on Drizzle instead of a raw driver
- MySQL Connection Pooling — how
connectionLimit,waitForConnections, andqueueLimitwork, independent of any framework - Using MySQL with ORMs — the settings that matter when an ORM sits between the application and MySQL

