Skip to content

Port portfolio from Vite SPA to TanStack Start with full SSR SEO - #9

Merged
frontendfixer merged 3 commits into
mainfrom
cursor/port-to-tanstack-start-seo-6e0e
Jul 24, 2026
Merged

Port portfolio from Vite SPA to TanStack Start with full SSR SEO#9
frontendfixer merged 3 commits into
mainfrom
cursor/port-to-tanstack-start-seo-6e0e

Conversation

@frontendfixer

Copy link
Copy Markdown
Owner

Summary

Ports the portfolio site from a client-only Vite + React Router SPA (with react-helmet-async for SEO) to TanStack Start, so the page is server-side rendered and search engine crawlers / social scrapers receive fully-rendered HTML with all meta tags in the initial response.

Why

Previously all SEO tags (<title>, description, Open Graph, Twitter, JSON-LD) were injected client-side by react-helmet-async. Crawlers that don't execute JS saw an empty <div id="root">. TanStack Start renders the document head on the server via the route head() API, giving genuine SEO support.

Stack changes

Before After
react-router-dom (client routing) @tanstack/react-router (file-based routing)
react-helmet-async (client meta) TanStack Start head() + HeadContent (SSR meta)
Vite SPA (index.html + main.tsx) TanStack Start SSR (__root.tsx document shell)
vite-aliases Explicit # aliases in vite.config.ts
Unused @reduxjs/toolkit / react-redux Removed
Static-only build SSR build served by a Bun-native production server

What changed

New TanStack Start structure

  • src/routes/__root.tsx — document shell (<html>/<head>/<body>, HeadContent, Scripts), global meta (charset, viewport, theme-color), favicons, web manifest, and JSON-LD structured data (Person / WebSite / Organization) rendered on every page.
  • src/routes/index.tsx — home route composing all existing sections, with per-page SEO head() (title, description, keywords, canonical, Open Graph, Twitter card).
  • src/router.tsxgetRouter() factory.
  • src/lib/seo.ts — ports the old SEO.tsx helper logic into TanStack head meta/links/scripts objects.
  • src/components/layout/Navigation.tsx — the old routes/Navigation.tsx, now a pure header (route Outlet moved to __root).

Runtime / deployment (Bun)

  • Serves the SSR build with TanStack Start's documented Bun production server (server.ts, Bun.serve), which serves dist/client static assets and forwards other requests to the dist/server/server.js SSR handler. This is the recommended Bun deployment path for a Vite build (React 19+, which this project uses), and fits the repo's existing Bun toolchain.
  • start script → bun run server.ts; the intermediate srvx dependency was removed.

Config / versions

  • vite.config.tstanstackStart() plugin (before React), explicit # path aliases replacing vite-aliases.
  • src/config/site.ts — SSR-safe fallback for VITE_SITE_URL (https://frontendfixer.dev).
  • package.json — swapped dependencies; engines bumped to node >=24 (latest LTS "Krypton") and bun >=1.3.14; packageManager stays bun@1.3.14 (the latest Bun release); added typecheck.
  • railway.tomlstartCommand + watch server.ts.
  • .gitignore / eslint.config.mjs — ignore generated routeTree.gen.ts, build output, and the standalone server.ts.

Removed: src/main.tsx, src/App.tsx, src/routes/Home.tsx, src/routes/Navigation.tsx, src/components/common/SEO.tsx, index.html.

All existing sections, styling (Tailwind v4 theme), assets, and the EmailJS contact form are preserved unchanged.

Verification

  • bun run dev and bun run build succeed; SSR output verified via curl — HTML contains rendered content plus <title>, description, Open Graph, Twitter card, canonical URL and all three JSON-LD schemas.
  • Production bun run server.ts verified: SSR HTML + hashed /assets/* files + static assets (/sitemap.xml, /robots.txt, /site.webmanifest, /favicon.ico) all return 200 with correct content types.
  • bun run lint, bun run typecheck, and bun install --frozen-lockfile all pass (matches CI).

Notes

  • Node latest is v26.5.0 (current) / v24.18.0 (LTS Krypton); the engine floor is set to the LTS line (>=24). Bun is already at its latest release (1.3.14).
  • The Nitro node-server target (.output) was evaluated but Nitro v3 currently requires Rolldown/Vite 7, so the Bun-native server is used to stay on Vite 6. Static prerendering can be added later once on a Vite 7 / Nitro setup; SSR already delivers full SEO today.
  • The environment needed bun + TanStack Start deps installed. Other cloud agents may benefit from an env setup pass at cursor.com/onboard.
Open in Web Open in Cursor 

cursoragent and others added 3 commits July 24, 2026 03:50
- Replace react-router-dom/react-helmet-async/vite-aliases/redux with
  @tanstack/react-start + @tanstack/react-router
- Add tanstackStart() vite plugin (before react) with explicit '#' aliases
- Serve the SSR build with srvx; update start script + railway.toml
- Ignore generated routeTree.gen.ts and build output

Co-authored-by: Lakshmikanta Patra <frontendfixer@users.noreply.github.com>
- Add document shell in src/routes/__root.tsx (HeadContent/Scripts,
  global meta, favicons, manifest, JSON-LD structured data)
- Add home route src/routes/index.tsx with per-page SEO head
- Add src/lib/seo.ts to build meta/links/JSON-LD from siteConfig
  (ports the old react-helmet SEO component to server-rendered head)
- Add src/router.tsx (getRouter) and move Navigation to components/layout
- Provide SSR-safe VITE_SITE_URL fallback
- Remove legacy SPA entry files (main, App, Home, old Navigation, SEO, index.html)

Co-authored-by: Lakshmikanta Patra <frontendfixer@users.noreply.github.com>
- Adopt TanStack Start's documented Bun production server (server.ts,
  Bun.serve) which serves dist/client + the dist/server SSR handler;
  matches our Vite build output and honors PORT
- start script -> 'bun run server.ts'; drop srvx dependency
- Bump engines (node >=24, bun >=1.3.14; bun 1.3.14 is the latest release)
- eslint: ignore standalone server.ts; railway: watch server.ts

Co-authored-by: Lakshmikanta Patra <frontendfixer@users.noreply.github.com>
@cursor

cursor Bot commented Jul 24, 2026

Copy link
Copy Markdown

Cursor Agent can help with this pull request. Just @cursor in comments and I'll start working on changes in this branch.
Learn more about Cursor Agents

@cursor

cursor Bot commented Jul 24, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@frontendfixer
frontendfixer merged commit d0c1fe9 into main Jul 24, 2026
1 check passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: edfa84f020

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread server.ts
gz,
etag,
type: metadata.type,
immutable: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid immutable caching for unversioned public files

With the default preload settings, every small file in dist/client is marked immutable here, including unversioned public files such as /robots.txt, /sitemap.xml, /site.webmanifest, favicons, and social preview images. createResponseHandler then serves them with a one-year Cache-Control: ... immutable, so updates to SEO metadata, icons, or social images can remain stale in browsers/CDNs for up to a year; reserve immutable caching for hashed build assets such as /assets/* and use a shorter or revalidating policy for root public files.

Useful? React with 👍 / 👎.

Comment thread server.ts
Comment on lines +255 to +256
headers['Content-Encoding'] = 'gzip'
headers['Content-Length'] = String(asset.gz.byteLength)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Vary cached gzip responses by Accept-Encoding

When a preloaded JS/CSS asset is requested with Accept-Encoding: gzip, this branch returns gzip bytes but does not add Vary: Accept-Encoding. Because these responses are also public/cacheable, a shared cache or CDN can store the gzip representation and replay it to a client that did not request gzip, producing an unreadable asset response; add the Vary header whenever gzip can be selected.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants