DEV Community

Cover image for Building GTA6 Grid: React, PostgreSQL Search, and SEO Beyond Meta Tags
Rohan Arora
Rohan Arora

Posted on Fully Autonomous

Building GTA6 Grid: React, PostgreSQL Search, and SEO Beyond Meta Tags

A character page, a vehicle catalog, and a news archive look like three different features. Underneath, they ask the same questions: What is this record? How do I find it? What should its URL return before JavaScript runs?

Those questions shaped GTA6 Grid, an unofficial GTA VI fan site I built with React, TypeScript, Vite, Express, and PostgreSQL.

The interface has the expected visual side: a cinematic homepage, location browsing, an illustrated map, trailers, and saved articles. But the more reusable engineering work sits behind it: a searchable content model, predictable HTTP responses, and a clear boundary between archived information and verified facts.

Here are the decisions behind that architecture, including a small PostgreSQL search example you can adapt to another content site.

1. Model the content before building the screens

Characters, vehicles, locations, and guides share a pages table. Each record has a stable path, a title, a category, an excerpt, and structured content. PostgreSQL JSONB columns hold blocks, facts, images, and videos.

That gives the application one browsing contract while allowing different article shapes. A character can have biographical facts; a vehicle can have a different set of attributes. Neither needs a separate search pipeline.

The model also stores source_url, published_at, scraped_at, and an evidence classification:

evidence TEXT NOT NULL DEFAULT 'Reported'
  CHECK (evidence IN ('Confirmed', 'Reported', 'Rumor'))
Enter fullscreen mode Exit fullscreen mode

Those labels describe the source archive's classification. They are not independent verification by GTA6 Grid. Keeping that distinction visible matters on a fan site where an old report can otherwise look like a current announcement.

Publication time and import time also answer different questions. An article imported today may describe something reported months ago. Sorting news by import time would make old information appear new.

Content preparation runs separately from visitor requests:

Archive preparation → review → PostgreSQL import
                                    ↓
Browser → Express API → catalog or article response
Enter fullscreen mode Exit fullscreen mode

Imports use ON CONFLICT (path) DO UPDATE inside a transaction. Reimporting the same paths updates their records instead of creating duplicate articles. That is useful repeatability, although changed content still needs editorial review.

2. Start search inside the database you already operate

This catalog needs ranked text search, categories, evidence filters, and pagination. PostgreSQL already provides the primitives for that workload, so the project does not need a separate search service to offer those features.

The actual schema stores a generated tsvector with three weights: title at A, excerpt at B, and article blocks at C. A GIN index supports matching against that document.

Here is a standalone, reduced example using only titles and excerpts. Run it in a scratch PostgreSQL database:

CREATE TABLE search_demo (
  path TEXT PRIMARY KEY,
  title TEXT NOT NULL,
  excerpt TEXT NOT NULL DEFAULT '',
  search_document TSVECTOR GENERATED ALWAYS AS (
    setweight(to_tsvector('english', title), 'A') ||
    setweight(to_tsvector('english', excerpt), 'B')
  ) STORED
);

CREATE INDEX search_demo_document_idx
  ON search_demo USING GIN (search_document);

INSERT INTO search_demo (path, title, excerpt) VALUES
  ('/places/harbor', 'Harbor District', 'Boats and waterfront roads.'),
  ('/guides/boats', 'Boat Guide', 'Exploring the harbor by water.');

WITH query AS (
  SELECT websearch_to_tsquery('english', 'harbor') AS value
)
SELECT path, title,
       ts_rank(search_document, query.value) AS rank
FROM search_demo
CROSS JOIN query
WHERE search_document @@ query.value
ORDER BY rank DESC, title;
Enter fullscreen mode Exit fullscreen mode

The generated column keeps the search document synchronized with its inputs. Weighting gives title matches a stronger contribution than excerpt matches. websearch_to_tsquery accepts familiar search input, including quoted phrases and OR; PostgreSQL documents its behavior in Controlling Text Search.

The application additionally allows a case-insensitive substring match on titles. That is helpful when a visitor remembers only part of a name, but it introduces a separate query-planning concern: a GIN index on the text-search vector does not automatically accelerate ILIKE '%term%'.

If that branch becomes expensive, I would inspect the complete query with EXPLAIN (ANALYZE, BUFFERS) and evaluate a trigram index or a different matching policy. The decision should follow the workload, rather than the presence of an index somewhere in the schema.

One further refinement: the current body vector uses the JSON blocks cast to text. Extracting only reader-visible block text would avoid indexing JSON field names.

3. Make the first HTTP response useful

A visitor can arrive directly at an article URL. A social preview service can request that same URL. Both need the page to describe itself without depending on an earlier visit to the homepage.

Express generates route-specific titles, descriptions, canonical URLs, social metadata, JSON-LD, and visible initial content. The response includes navigation, headings, article text, and relevant links before the browser executes the React application.

The current flow is:

URL + PostgreSQL content
          ↓
Express → HTML + route metadata
          ↓
Browser loads React → interactive interface
Enter fullscreen mode Exit fullscreen mode

There is an important implementation detail here: the current client uses createRoot, so React replaces the initial markup when it renders. This is not React hydration.

React's createRoot documentation explains that the first render clears existing content inside the root. hydrateRoot reuses matching server-rendered React markup instead.

A future hydration implementation would need shared rendering and initial data so the server and browser produce matching output. Swapping the API name alone would not establish that contract. The present approach provides readable initial HTML, while accepting a second rendering pass in the browser.

4. Treat URL behavior as part of the application

An article, a category page, and a search result should not all follow identical indexing rules.

GTA6 Grid uses one route metadata module to drive the server response, client navigation metadata, and sitemap eligibility. The policy distinguishes durable content from utility views:

  • Articles and main collections get their own canonical URLs.
  • Search, saved articles, and legal pages receive noindex and stay outside the sitemap.
  • Catalog search and filter variants receive noindex with a clean-path canonical.
  • Unknown routes return HTTP 404.
  • Trailing-slash and /index.html variants redirect to the preferred form.

For example, /vehicles?q=car currently returns:

<meta name="robots" content="noindex, follow">
<link rel="canonical" href="https://gta6grid.com/vehicles">
Enter fullscreen mode Exit fullscreen mode

These directives serve different purposes. noindex excludes the utility view from search results; the canonical identifies the preferred URL. A canonical is a signal, not a guarantee that a search engine will select that URL, as Google's canonicalization documentation explains.

The filtered pages remain crawlable. Blocking them in robots.txt would prevent a crawler from seeing their noindex directive. Google documents that requirement.

Client navigation matters too. When the visitor changes routes without a full reload, the React metadata component requests /api/seo and updates the document head. It aborts obsolete requests so an earlier response cannot overwrite a newer page's identity.

5. Keep browsing responses and personal state small

The public API separates listing from detail:

GET /api/pages?category=vehicles&q=car&limit=24&offset=0
GET /api/page?path=/characters/lucia-caminos
Enter fullscreen mode Exit fullscreen mode

The listing returns summary fields. The detail endpoint returns blocks, facts, and media for one article. Opening a catalog therefore does not require downloading every article body.

The API validates category and evidence values, binds user input as SQL parameters, and bounds pagination. List requests default to 24 records and cap at 60. Those boundaries make the response contract predictable for both the server and the interface.

Saved articles take a different path: they live in the browser's localStorage, alongside the theme preference. The application can offer bookmarking without an account system. The tradeoff is explicit: those saves belong to that browser, do not synchronize across devices, and disappear if the visitor clears site data.

What I would carry into another content project

The most transferable decisions were the ones that made several features agree with each other:

  • A stable content path supports imports, article lookup, and canonical URLs.
  • A shared metadata policy keeps direct requests and client navigation consistent.
  • Evidence and timestamps preserve context as archived information ages.
  • Database search combines naturally with the catalog's existing filters.

You can explore GTA6 Grid and follow a search result through to its article to see that structure in use. The project is an unofficial fan site, unaffiliated with Rockstar Games or Take-Two Interactive.

For your next content-heavy React project, which would you prioritize first: PostgreSQL search relevance, shared server/client rendering, or stronger content provenance?

Top comments (0)