Databases for Frontend Developers: A Practical Guide

Imad Attif, Sr. Frontend Engineer
25 min read
Apr 15, 2026
Frontend development stopped ending at the API boundary a while ago. If you write Next.js server components, API routes, or server actions, you're querying a database, whether you chose it or not. And most database guides for frontend developers either drown you in DBA trivia or hand-wave everything as "just use Postgres."
This guide takes a middle path. It covers the five database types you'll actually encounter (relational, document, key-value, graph, and columnar), what each one is genuinely good at, the one performance concept you can't skip (indexes), and how to talk to a database from JavaScript without hurting yourself. Wherever possible, I'll map database ideas onto frontend concepts you already know.
The short version, if you only read one paragraph: default to PostgreSQL, add Redis when you need caching or ephemeral state, and reach for the other types only when your data's shape genuinely demands it. The rest of this post is the reasoning behind that sentence.
What is a database, really?
Strip away the vendor marketing and a database does three things your data.json file can't:
- Survives concurrency. A hundred users can read and write at the same time without corrupting each other's changes.
- Answers questions fast. "All orders from this user, newest first" comes back in milliseconds even with fifty million orders, because of clever data structures, not brute force.
- Enforces rules. No user without an email, no order pointing at a deleted product. The database refuses bad data instead of trusting every code path to behave.
One term you need before anything else: schema. A schema is the declared structure of your data: which fields exist, their types, what's required. If you know TypeScript, you already have the right instinct. A schema is types for your data at rest, and the same debate you've had about any versus strict types plays out between database families:
- Strict-schema databases (Postgres and other relational systems) are like TypeScript with
strict: true. You declare the shape up front, and the database rejects anything that doesn't match. - Schemaless databases (MongoDB and other document stores) are like plain JavaScript. Store whatever object you like; the flexibility is yours, and so is the responsibility when two documents in the same collection turn out to have different shapes.
Neither is "modern" or "legacy." They're a trade-off, and we'll come back to it.
Relational databases: PostgreSQL and SQL
Relational databases store data in tables: think spreadsheets with strictly typed columns. Each row is a record, each column is a field, and SQL is the language for asking questions about them. This family (PostgreSQL, MySQL, SQLite) has been the default for fifty years, and PostgreSQL in particular has become the modern consensus pick.
Here's a schema for a tiny message board:
1CREATE TABLE users (2 id SERIAL PRIMARY KEY,3 username VARCHAR(50) UNIQUE NOT NULL,4 email VARCHAR(255) NOT NULL,5 created_at TIMESTAMPTZ DEFAULT NOW()6);78CREATE TABLE posts (9 id SERIAL PRIMARY KEY,10 user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,11 title VARCHAR(200) NOT NULL,12 body TEXT,13 created_at TIMESTAMPTZ DEFAULT NOW()14);
Notice how much is rules, not just structure. NOT NULL and UNIQUE are constraints the database enforces on every write, from any codebase, forever. And REFERENCES users(id) is a foreign key: a post must point at a real user, and ON DELETE CASCADE says that deleting a user deletes their posts too, automatically.
That foreign key is the "relational" part. Instead of nesting the author inside every post (as you would in a JSON blob), each fact lives in exactly one place and rows reference each other. If you've used a normalized store in Redux or Relay on the client, this is the same idea and the same payoff: update the user's name in one row, and every post reflects it, because nothing was duplicated.
To read across tables, you join them back together:
1-- The 10 newest posts, with their authors2SELECT posts.title, posts.created_at, users.username3FROM posts4JOIN users ON users.id = posts.user_id5ORDER BY posts.created_at DESC6LIMIT 10;
SQL looks alien next to JavaScript, but it's a small language, and the core (SELECT, WHERE, JOIN, GROUP BY, ORDER BY) covers 95% of what an application needs. It's also the most transferable skill in this post: every database in this guide except Redis and Neo4j speaks SQL or something shaped like it.
Choose relational when: your data has structure and relationships, which is most application data: users, teams, products, orders, comments. When in doubt, this is the default.
The SQL crash course: everything you'll actually use
SQL has hundreds of features, but application code leans on a small core. This section teaches that core, and the fastest way in is a mapping you already know. Think of a table as an array of objects:
WHEREis.filter()- the
SELECTcolumn list is.map()picking fields ORDER BYis.sort()LIMITandOFFSETare.slice()GROUP BYisObject.groupBy()- a
JOINmatches items across two arrays by a shared key
The difference is that the database does all of it inside the engine, with indexes, before the data ever crosses the network. Fetching everything and filtering in JavaScript is the equivalent of downloading a full-resolution image to crop it client-side.
All examples below use the users and posts tables we created earlier.
Reading data: SELECT
1-- Pick fields (.map), filter rows (.filter), sort (.sort), page (.slice)2SELECT id, title, created_at3FROM posts4WHERE created_at > '2026-01-01'5 AND title ILIKE '%launch%' -- case-insensitive contains6ORDER BY created_at DESC7LIMIT 20 OFFSET 40; -- page 3, 20 per page
The WHERE toolkit covers the comparisons you'd expect: =, <> (not equal), >, <, plus a few worth memorizing. IN ('a', 'b') matches against a list. BETWEEN '2026-01-01' AND '2026-06-30' is an inclusive range and works on dates. ILIKE '%launch%' is a case-insensitive substring match, with % as the wildcard. IS NULL checks for missing values (= NULL doesn't work; NULL isn't equal to anything, including itself, which will bite you exactly once).
Writing data: INSERT, UPDATE, DELETE
1INSERT INTO posts (user_id, title, body)2VALUES (7, 'Hello world', 'First post!')3RETURNING id, created_at; -- get the generated values back, no second query45UPDATE posts6SET title = 'Hello, world'7WHERE id = 42;89DELETE FROM posts10WHERE id = 42;
The critical habit: UPDATE and DELETE apply to every row that matches the WHERE clause, and without a WHERE clause they apply to the whole table. There is no confirmation prompt. Two protections worth adopting: write the WHERE first (or run it as a SELECT first to see what would match), and wrap risky changes in a transaction so you can inspect before committing:
1BEGIN;2DELETE FROM posts WHERE user_id = 7;3-- check: SELECT COUNT(*) FROM posts WHERE user_id = 7; → 0, good4COMMIT; -- or ROLLBACK; to undo everything since BEGIN
Transactions are the database's superpower over your JSON file: every statement between BEGIN and COMMIT succeeds or fails as a unit.
Summarizing data: COUNT and GROUP BY
Aggregations collapse many rows into summary rows, exactly like reducing a grouped object:
1-- How many posts total?2SELECT COUNT(*) FROM posts;34-- Posts per user, most prolific first, only users with 5+5SELECT user_id, COUNT(*) AS post_count6FROM posts7GROUP BY user_id8HAVING COUNT(*) >= 59ORDER BY post_count DESC;
GROUP BY user_id buckets the rows per user; COUNT(*) runs per bucket. HAVING is just WHERE for the buckets (you can't use WHERE there, because it filters rows before grouping, while HAVING filters groups after). The other aggregate functions read like their names: SUM, AVG, MIN, MAX.
Combining tables: joins
We saw an inner join earlier. The part nobody explains clearly is the difference between join types, and it's really one question: what happens to rows that have no match?
- An INNER JOIN keeps only rows that match on both sides. A user with zero posts simply doesn't appear in the result.
- A LEFT JOIN keeps every row from the left table, matched or not; missing right-side values come back as NULL. The zero-post user appears with NULLs for the post columns.
- A RIGHT JOIN is the mirror image, and in practice you'll almost never write one, because you can always swap the table order and use LEFT.
Which one you want depends on the sentence you're trying to say. "Posts with their authors" is an inner join. "All users, with their post count, including the ones who never posted" needs a left join, and combines everything from this section:
1SELECT users.username, COUNT(posts.id) AS post_count2FROM users3LEFT JOIN posts ON posts.user_id = users.id4GROUP BY users.username5ORDER BY post_count DESC;
With an INNER JOIN, the lurkers would silently vanish from that list, which is exactly the kind of bug that ships to production because it doesn't error, it just quietly lies. When a report seems to be "missing" rows, an inner-join-where-you-meant-left is the first suspect.
(One naming note: COUNT(posts.id) rather than COUNT(*) matters here, because it doesn't count the NULL placeholder row a postless user gets, so they correctly report zero.)
Subqueries: queries inside queries
A subquery uses one query's result inside another, like an intermediate variable:
1-- Posts written by users who signed up this year2SELECT title FROM posts3WHERE user_id IN (4 SELECT id FROM users WHERE created_at >= '2026-01-01'5);
Most subqueries can be rewritten as joins, and vice versa. Modern Postgres usually optimizes both to the same plan, so pick the more readable one, and if a query is slow, that's a job for EXPLAIN (covered below), not for guessing.
That's genuinely most of it
SELECT with WHERE, ORDER BY and LIMIT, the three write statements, GROUP BY with aggregates, inner and left joins, and the occasional subquery: this is the working set that covers around 95% of application queries, including the SQL your ORM generates on your behalf. Everything else (window functions, CTEs, recursive queries) is learnable on demand once this core is comfortable.
What if my data doesn't fit a rigid schema? JSONB
Here's the plot twist most SQL-vs-NoSQL articles miss: Postgres has a first-class JSON column type, JSONB, and it's indexable and queryable. So "I need flexible data" doesn't mean leaving the relational world.
The classic use case is metadata that varies per row. Say posts can have arbitrary settings you don't want to model as twenty nullable columns:
1ALTER TABLE posts ADD COLUMN metadata JSONB DEFAULT '{}';23UPDATE posts4SET metadata = '{"pinned": true, "tags": ["announcement"], "poll": {"ends": "2026-09-01"}}'5WHERE id = 42;67-- Query inside the JSON8SELECT title FROM posts9WHERE metadata->>'pinned' = 'true';
The pragmatic pattern: structured columns for the data you query and rely on, JSONB for the long tail. You get constraints and joins where they matter and schema freedom where it doesn't. For a lot of apps, this removes the main argument for a document database entirely.
(Use JSONB, not the older JSON type; JSONB is stored in a parsed binary form that's much faster to query.)
Document databases: MongoDB
A document database stores JSON-like documents in collections (its word for tables). No declared schema: insert whatever object matches your mental model, nesting included.
1db.posts.insertOne({2 title: 'Welcome to the board',3 author: { username: 'imad', joined: ISODate('2024-01-15') },4 tags: ['announcement'],5 comments: [6 { user: 'sam', body: 'First!', at: ISODate('2026-08-01') },7 ],8});910// Queries are JavaScript-flavored too11db.posts.find(12 { tags: 'announcement', 'author.username': 'imad' },13 { title: 1, 'comments.body': 1 } // projection: pick fields14);
For a JavaScript developer this feels wonderful on day one. Your database records look exactly like your runtime objects; there's no SQL, no translation layer.
The trade-off arrives later, and it's the normalization one in reverse. That embedded author is duplicated into every post the user writes. When they change their username, you're updating thousands of documents, and any code path that forgets leaves stale data behind. Document databases shine when data is naturally self-contained (the document really is the unit: a form submission, a product listing with its variants, an event payload) and struggle when data is highly relational and you keep reimplementing joins in application code.
Two honest notes. First, MongoDB does have joins ($lookup) and optional schema validation; the stereotype that it can't is outdated, but neither is its strong suit. Second, the "schemaless" freedom is often an illusion: your application still assumes a shape, and the schema just lives in your code's expectations instead of somewhere enforced. TypeScript developers usually end up wanting the enforcement back.
Choose a document database when: records are self-contained, shapes genuinely vary, and you rarely need cross-entity queries. If you're mostly storing and retrieving JSON blobs by ID, it's a fine fit.
Key-value stores: Redis
A key-value store is the simplest model here: a giant, extremely fast dictionary. SET a value under a key, GET it back, microseconds each way, because everything lives in memory.
1SET user:1042:session '{"userId": 1042, "role": "admin"}'2EXPIRE user:1042:session 3600 # auto-delete after an hour34GET user:1042:session5INCR pageviews:home # atomic counter, no race conditions
Redis is the standard tool in this family, and the frontend analogy is exact: Redis is memoization as a service. The same way you cache an expensive computation in a Map keyed by its inputs, you cache expensive database queries or API responses in Redis keyed by whatever identifies them, shared across all your server instances.
That EXPIRE command is the feature that defines the use cases. Data that should vanish on its own is Redis-shaped data:
- Caching: the rendered result of an expensive query, keyed by its parameters, with a TTL.
- Sessions: who's logged in, expiring after inactivity. (A Redis-backed session pointed at by an HTTP-only cookie is the classic setup.)
- Rate limiting:
INCRa counter per user per minute, block above a threshold. - Queues and realtime plumbing: lists and pub/sub make Redis a common backbone for background jobs.
Beyond strings, Redis has lists, sets, sorted sets (leaderboards in one command), and hashes. What it doesn't have: queries. You can't ask Redis "which sessions belong to admins?" If you need to query by anything other than the key, the data belongs somewhere else.
Choose Redis when: the data is ephemeral, the access pattern is "by key," and speed matters. It's almost always a second database next to your primary one, not a replacement for it.
Graph databases: Neo4j
Some questions are about the connections more than the things. "Which friends of my friends work at companies I've applied to?" In SQL, every hop through a relationship is another join, and a query that traverses five hops becomes both unreadable and slow. Graph databases flip the model: relationships are stored as first-class records, not computed through joins, so traversing them is cheap.
Neo4j is the best-known one. Its query language, Cypher, draws the pattern you're looking for with ASCII art:
1// Find every actor who worked with someone who worked with Kevin Bacon2MATCH (kevin:Actor {name: 'Kevin Bacon'})-[:ACTED_IN]->(:Movie)3 <-[:ACTED_IN]-(coactor:Actor)-[:ACTED_IN]->(:Movie)4 <-[:ACTED_IN]-(twoDegrees:Actor)5RETURN DISTINCT twoDegrees.name;67// Or just ask for the shortest path between two people8MATCH p = shortestPath(9 (kevin:Actor {name: 'Kevin Bacon'})-[:ACTED_IN*..8]-(other:Actor {name: 'Tom Hanks'})10)11RETURN length(p) / 2 AS degrees;
The (node)-[:RELATIONSHIP]->(node) syntax is the data model: nodes with labels and properties, connected by typed relationships. The "six degrees of Kevin Bacon" query above would be a nightmare of recursive self-joins in SQL; in Cypher it's one readable line.
Real-world graph problems: social networks, recommendation engines ("users who bought what you bought also bought..."), fraud detection (rings of accounts sharing addresses and cards), and org or permission hierarchies with deep nesting.
Choose a graph database when: multi-hop relationship traversal is a core feature of your product, not an occasional query. Most apps never hit this bar, and a few recursive queries in Postgres cover the shallow cases. It's a specialist tool, usually alongside a relational primary.
Columnar databases: DuckDB and the analytics world
Everything so far is optimized for transactions (OLTP in the jargon): fetch this user, update that order, thousands of small operations touching a few rows each. Analytics is the opposite shape (OLAP): "average order value per month across all fifty million orders" touches every row but only two columns.
Row-oriented storage is wrong for that: reading two columns means reading entire rows anyway. Columnar databases store each column together instead, so an aggregation reads only the columns it needs, compressed tightly because similar values sit side by side. The result is analytical queries running orders of magnitude faster.
This is the world of data warehouses (BigQuery, Snowflake, ClickHouse) and file formats like Parquet (columnar files that data teams pass around the way frontend devs pass around JSON). The delightful entry point for a JavaScript developer is DuckDB: a columnar engine that runs in-process, no server, and queries files directly:
1-- Query a Parquet file straight from disk, no import step2SELECT product_category, AVG(amount) AS avg_order3FROM 'orders.parquet'4WHERE created_at >= '2026-01-01'5GROUP BY product_category6ORDER BY avg_order DESC;
DuckDB is SQLite's analytics twin: perfect for crunching a data export, powering an internal dashboard, or exploring a dataset too big for Excel, all without infrastructure. There's even a WASM build that runs it in the browser.
Choose columnar when: the question starts with "across all our data...". Keep serving your app from Postgres and run analytics on the side.
Indexes: the one performance concept you must understand
This is the section that will save you a production incident, whichever database you picked.
Without an index, a query like WHERE email = '...' forces the database to check every row (a sequential scan). At a thousand rows, who cares. At ten million, your login endpoint takes seconds. An index is a sorted lookup structure the database maintains next to the table, letting it jump straight to matching rows instead of scanning. Like the index in a book: you don't read the book to find one topic.
1-- Before: full scan2EXPLAIN SELECT * FROM users WHERE email = 'imad@example.com';3-- Seq Scan on users (cost=0.00..18334.00 ...)45CREATE INDEX users_email_idx ON users (email);67-- After: index lookup8EXPLAIN SELECT * FROM users WHERE email = 'imad@example.com';9-- Index Scan using users_email_idx (cost=0.42..8.44 ...)
EXPLAIN is the database's version of the DevTools Performance panel: it shows you the plan and the estimated cost instead of leaving you to guess. Every database in this post has an equivalent (explain() in MongoDB, EXPLAIN in Cypher and DuckDB).
Three things to internalize:
- Indexes aren't free. Every write now also updates every index on the table. Indexing every column "just in case" makes reads marginally better and writes meaningfully worse. Add indexes when a real query is slow, not preemptively.
- Compound indexes match query shapes. An index on
(board_id, created_at)makes "newest posts on this board" fast; two separate single-column indexes don't achieve the same thing. Index the combination you actually query. - Primary keys and UNIQUE constraints are already indexed. Lookups by ID are fast out of the box; it's your
WHERE/ORDER BYcolumns on big tables that need attention.
If you take one habit from this post: when a page is slow, EXPLAIN the query before reaching for a caching layer. It's usually a missing index, and the fix is one line.
Vector search: databases meet AI
One newer capability worth knowing, because product managers will ask: vector search, the database half of RAG (retrieval-augmented generation).
The idea in three steps. An embedding model turns text into a vector (an array of numbers) where similar meanings land near each other, so "how do I reset my password" and "forgot my login credentials" end up close despite sharing no words. You store those vectors in a database. At question time, you embed the user's query and ask the database for the nearest stored vectors, then feed those chunks to an LLM as context for its answer.
You don't need a new database for this. Postgres's pgvector extension adds a vector column type and a nearest-neighbor operator:
1CREATE EXTENSION vector;23CREATE TABLE docs (4 id SERIAL PRIMARY KEY,5 content TEXT,6 embedding VECTOR(1536) -- dimension must match your embedding model7);89-- Top 5 most semantically similar chunks to the query embedding10SELECT content11FROM docs12ORDER BY embedding <=> $1 -- <=> is cosine distance13LIMIT 5;
A warning from the trenches: RAG quality is retrieval quality. If the search returns irrelevant chunks, the LLM confidently answers from irrelevant context, and bad data in the prompt is worse than no data. Before building a RAG pipeline, check whether plain full-text search (also built into Postgres) solves the problem; it's cheaper, debuggable, and often enough.
Talking to a database from JavaScript
Whatever you chose, the JavaScript side has one iron rule and one big decision.
The iron rule: never interpolate user input into a query string. This is SQL injection, the database equivalent of dangerouslySetInnerHTML with user content, and it's still the classic way apps get owned:
1import pg from 'pg';2const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });34// ❌ Injection: a username of "'; DROP TABLE users; --" ruins your day5const bad = await pool.query(6 `SELECT * FROM users WHERE username = '${username}'`7);89// ✅ Parameterized: the driver sends the value separately from the query10const good = await pool.query(11 'SELECT * FROM users WHERE username = $1',12 [username]13);
Parameterized queries exist in every driver (Postgres's $1, MongoDB's structured queries, Neo4j's $params). Use them unconditionally.
The big decision: raw queries or an ORM. Tools like Drizzle and Prisma give you typed schemas in TypeScript, generated migrations (versioned, reviewable scripts for evolving your schema, like commits for your database's shape), and autocompleted queries. The trade-off is a layer of abstraction between you and the SQL that's actually executed. A sane path for a frontend engineer: use Drizzle or Prisma for productivity and type safety, but learn enough SQL to read what they generate and to EXPLAIN it when something is slow. The ORM is the framework; SQL is the platform underneath it.
How do you choose a database?
The honest flowchart, in prose:
- Start with PostgreSQL. Relational, strict schema, JSONB for the flexible parts, full-text search, pgvector for AI features. It covers the 90% case, every host offers it, every tool supports it, and every LLM knows it deeply.
- Add Redis when you have data with a lifespan: caching, sessions, rate limits, queues.
- Reach for a document store if your records are genuinely self-contained JSON and relationships are rare.
- Reach for a graph database if multi-hop relationship traversal is the product.
- Reach for columnar/DuckDB when analytics questions start scanning your whole dataset.
Two boring factors that outweigh the technical ones. Familiarity: a database your team knows beats a marginally better one nobody can debug at 3am; your team's experience is a legitimate architectural input, not a bias. Operational complexity: every extra database is another thing to host, back up, secure, monitor, and migrate. A second database has to earn its keep; a third one really has to.
Also normal and worth knowing: real systems combine them. A typical production setup is Postgres as the source of truth, Redis in front of it for speed, and a warehouse on the side for analytics, each doing what it's shaped for.
FAQ
What's the difference between SQL and NoSQL? SQL databases store strictly-typed tables with enforced relationships and are queried with SQL. NoSQL is an umbrella for everything else: document stores (MongoDB), key-value stores (Redis), graph databases (Neo4j), and more. The real distinction isn't age or scale; it's the data model and how strictly the schema is enforced.
Should I use PostgreSQL or MongoDB? Default to PostgreSQL. Its JSONB columns cover most "we need flexible documents" needs while keeping constraints, joins, and transactions. Pick MongoDB when your data is genuinely document-shaped: self-contained records, varying shapes, few cross-entity queries.
What is Redis actually used for? Fast, ephemeral, key-addressed data: caching expensive results, login sessions, rate limiting, counters, and job queues. It complements a primary database rather than replacing it, because it can't query by anything except the key.
Do frontend developers really need SQL? If you write server components, API routes, or server actions, you're already using it, possibly through an ORM. Enough SQL to write a join and read an EXPLAIN plan is a small investment that pays off every time something is slow, and it makes you far more effective with ORMs like Drizzle and Prisma.
When do I need a dedicated vector database? Later than you think. pgvector handles vector search inside Postgres well into millions of vectors, alongside the rest of your data and with normal SQL filters. Dedicated vector databases make sense at large scale or with specialized retrieval needs.
Summary
- A database gives you concurrency safety, fast queries, and enforced rules. A schema is types for your data at rest.
- PostgreSQL (relational) is the default: tables, constraints, joins, plus JSONB when parts of your data need flexibility.
- MongoDB (document) fits self-contained, varying-shape records; it trades away enforced relationships.
- Redis (key-value) is memoization as a service: caching, sessions, rate limits, anything with a TTL.
- Neo4j (graph) makes multi-hop relationship queries trivial; it's for products built on connections.
- DuckDB and columnar engines answer "across all our data" questions that would crush a row store.
- Indexes are the performance lever:
EXPLAINslow queries, index what you filter and sort by, don't index preemptively. - From JavaScript: parameterized queries always, an ORM like Drizzle or Prisma for comfort, and enough SQL to see through it.
Pick boring, pick familiar, and let the shape of your data, not the hype cycle, tell you when to add something exotic.