Skip to content

Conversation

@depsimon
Copy link
Contributor

Description

This PR updates the tanstack-start example to use the latest version which relies on vite instead of vinxi. It also updates to tailwind 4

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Examples update

Could be fixing TanStack/router#4279 & TanStack/router#4409

Checklist

  • I have read the CONTRIBUTING and CODE_OF_CONDUCT docs
  • I have added tests that prove my fix is effective or that my feature works
  • I have added the necessary documentation (if appropriate)

@vercel
Copy link

vercel bot commented Jun 18, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Updated (UTC)
js-lingui ✅ Ready (Inspect) Visit Preview Jun 23, 2025 7:21pm

@github-actions
Copy link

github-actions bot commented Jun 18, 2025

size-limit report 📦

Path Size
packages/core/dist/index.mjs 2.91 KB (0%)
packages/detect-locale/dist/index.mjs 618 B (0%)
packages/react/dist/index.mjs 1.35 KB (0%)

@codecov
Copy link

codecov bot commented Jun 18, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 76.27%. Comparing base (6bb8983) to head (2dff3b9).
⚠️ Report is 231 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2267      +/-   ##
==========================================
- Coverage   77.05%   76.27%   -0.78%     
==========================================
  Files          84       99      +15     
  Lines        2157     2651     +494     
  Branches      555      692     +137     
==========================================
+ Hits         1662     2022     +360     
- Misses        382      505     +123     
- Partials      113      124      +11     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@depsimon depsimon changed the title Devinxi Tanstack Start example fix(examples): devinxi Tanstack Start example Jun 18, 2025
@timofei-iatsenko
Copy link
Collaborator

I briefly checked the integration, and came across that global i18n instance used in some places eq import { i18n } from "@lingui/core"

The global i18n should be never ever used in server side rendered applications because all requests to the server would share the same context and using one global instance will cause a mess

@depsimon
Copy link
Contributor Author

@timofei-iatsenko thanks for taking a look.

What would you use instead of the global i18n instance? I'm not in production yet, but have not seen any particular issue so far in local env.

@timofei-iatsenko
Copy link
Collaborator

@depsimon you will not see it in local development because you don't have a concurent access to your server-rendered app. You need to have a users which will access app at the same time and then you will get issues.

The basic rule - you should create one i18n instance per request and share this instance everywhere. I'm not particulare familiar with tanstack start, so i could not help you with an example.

@timofei-iatsenko
Copy link
Collaborator

I'm briefly checking the docs for tanstack start + tanstack router and probably Router Context + beforeLoad in the rootRoute https://tanstack.com/start/latest/docs/framework/react/learn-the-basics#the-root-of-your-application is the right place to explore integration.

You might want to create i18n instance in before load hook and then pass it down using a RouterContext.

@depsimon
Copy link
Contributor Author

Thanks for the pointers, I'll try to take a look at this before merging.

It should be trivial to simply add the i18n instance to the router context in the src/router.tsx as we already pass the instance to the createRouter. That will make it available in loaders.

It might be a bit trickier to pass it around in server routes (APIs).

@timofei-iatsenko
Copy link
Collaborator

I pushed a commit which is getting rid of all global i18n instances usages. I think that the integration could be improved even more, instead of loading a catalogs using asynchronous import (await import(`../../locales/${locale}/messages.po`)) on client side, catalogs could be fetched / injected from server side to the client side using tanstack-start methods.

This will ensure that the loading of the dynamic catalog would follow the same invalidation / awaiting rules as other async recourses and you will not need handle loading state for catalogs separately.

I think that the starting point might be load method of the route or server function. This way catalog would be revalidated and refetched on router.invalidate() method.

Please check the code i pushed, because i didn't test all possible cases.

@depsimon
Copy link
Contributor Author

I pushed a commit which is getting rid of all global i18n instances usages.
..
Please check the code i pushed, because i didn't test all possible cases.

Thanks @timofei-iatsenko I definitely missed the setupI18n().

I updated the PR to remove unused imports & add a couple missing translations.

The middleware is great, I used it in a project, the only downside is that this code is run twice for each API request that use the middleware:

const locale = getLocaleFromRequest()
const i18n = setupI18n({})

await dynamicActivate(i18n, locale)

You can attest this by adding a log in the getLocaleFromRequest(). One is from the server.ts and the other from the middleware.
I haven't found a way to pass the i18n from the server to the server route context though, so I suppose that's the next best solution. Not sure how it affects the perfs to loadAndActivate twice.

I think that the integration could be improved even more, instead of loading a catalogs using asynchronous import (await import(`../../locales/${locale}/messages.po`)) on client side, catalogs could be fetched / injected from server side to the client side using tanstack-start methods.

This will ensure that the loading of the dynamic catalog would follow the same invalidation / awaiting rules as other async recourses and you will not need handle loading state for catalogs separately.

I think that the starting point might be load method of the route or server function. This way catalog would be revalidated and refetched on router.invalidate() method.

Interesting, we might achieve this in the createRouter() method. I think that's what tanstack/query does with their routerWithQueryClient() wrapper. I'll try to dig into it to check how they do hydrate the query states from the server.

@timofei-iatsenko
Copy link
Collaborator

The middleware is great, I used it in a project, the only downside is that this code is run twice for each API request that use the middleware:

that's fine, once es module loaded next time it will be taken from cache. it will not affect performance.

Interesting, we might achieve this in the createRouter() method. I think that's what tanstack/query does with their routerWithQueryClient() wrapper. I'll try to dig into it to check how they do hydrate the query states from the server.

Yes i also was curios what is the recomended way to pass a hydration state from server to client. And was a bit disapointed when discovered that they are not showing how to do that and hide all logic in the routerWithQueryClient

@depsimon
Copy link
Contributor Author

@timofei-iatsenko I managed to make a simple router wrapper that handle the catalog hydration.

I'm not sure how I did as I never played with that, but it seems to work.

We could also delete the client.tsx if we tweak the createRouter() a little like this:

export function createRouter({ i18n: serverI18n }: { i18n?: I18n } = {}) {
  const i18n = serverI18n ?? setupI18n({})

  const router = routerWithLingui(createTanStackRouter({
    routeTree,
    context: {
      i18n,
    },
    defaultErrorComponent: DefaultCatchBoundary,
    defaultNotFoundComponent: () => <NotFound />,
    scrollRestoration: true
  }), i18n)

  return router
}

This way, only the server sets the locale & loads the initial catalog. Then it's loaded in the client.

WDYT?

@timofei-iatsenko
Copy link
Collaborator

@timofei-iatsenko I managed to make a simple router wrapper that handle the catalog hydration.

I'm not sure how I did as I never played with that, but it seems to work.

We could also delete the client.tsx if we tweak the createRouter() a little like this:

export function createRouter({ i18n: serverI18n }: { i18n?: I18n } = {}) {
  const i18n = serverI18n ?? setupI18n({})

  const router = routerWithLingui(createTanStackRouter({
    routeTree,
    context: {
      i18n,
    },
    defaultErrorComponent: DefaultCatchBoundary,
    defaultNotFoundComponent: () => <NotFound />,
    scrollRestoration: true
  }), i18n)

  return router
}

This way, only the server sets the locale & loads the initial catalog. Then it's loaded in the client.

WDYT?

So hydrate/dehydrate is the magic behind the routerWithQueryClient? need to battle test this approach in a real world application to understand limitations / flaws.

I also don't like the fact that when you switch the language the catalog will be loaded in a different way. So imagine the situation:

  • open app in the EN
  • catalog loaded from the server using hydration/dehydration
  • switched locale to the FR, catalog loaded using import (....)
  • switch to EN again, catalog should be loaded again because ES module of the en catalog was never loaded on the client side.

I'm actually a big fun of the "stateless" as much as possible approach for the clientside applications. That means when you switch the langauge, i would assume that the whole page will reload from scratch. Because approach with switching language "on the fly" is requires many assumptions/testing and generally effort which is not really worth it.

You need not only switch the translation of messages which is in the codebase, but also keep track that all resources will be reload on the correct language, if user in the middle of filling the form, all fields will be populated for correct language and so on.

@depsimon
Copy link
Contributor Author

I've added an example page that uses a /$lang/ route segment. I chose not to put everything under that segment for simplicity.

Not really, it will always be loaded from the server on the navigation, there will be no call to any lingui methods other then navigating with a router.

Are you sure? In TSS, the loader is called client-side except for the first (SSR) request. Only the server functions are called in the server, but that wouldn't let us manipulate the client's i18n instance.

Anyway, what i'm trying to bring here, due to the fact that this example in the official repo, people will treat it as "official" way of integration. Sometimes just copying without wondering. So i believe it should showcase best practicies, not the dirty hacks. As i said the best practice to support SEO and caching and static site generation would be to have a separate path for each locale

Do you think the /$lang/ example is sufficient? I am pretty sure that the user will reach out to a CMS or a backend of sorts that will manage all the translated content which won't require lingui in the web app.

In such a case, the /$lang/ example is enough and the user would probably reach for a /$lang/$slug route that'll fetch the correct content and render it.

@timofei-iatsenko
Copy link
Collaborator

@depsimon here is the code what i had in mind:

// $lang/route.tsx
import { createFileRoute, notFound, Outlet } from "@tanstack/react-router"
import { updateLocale } from "~/functions/locale"
import { loadCatalog, locales } from "~/modules/lingui/i18n"
import { I18nProvider } from "@lingui/react"
import { setupI18n } from "@lingui/core"

export const Route = createFileRoute("/$lang")({
  component: Page,
  async loader({ context, params }) {
    if (!Object.keys(locales).includes(params.lang)) {
      throw notFound()
    }

    const messages = loadCatalog(params.lang)

    await updateLocale({ data: params.lang }) // Persist the locale in the cookies

    return messages
  },
})

function Page() {
  const messages = Route.useLoaderData()
  const { lang } = Route.useParams()

  const i18n = setupI18n({ messages: { [lang]: messages }, locale: lang })

  return (
    <I18nProvider i18n={i18n}>
      <Outlet />
    </I18nProvider>
  )
}

This route should be a parent of all routes.

The loader indeed is isomorphic, so first time it's requesting data from SSR next time on client navigations it's executed on the client side. But this is OK, what i'm trying to achieve here is that initializtion and langauge switching is uniform. So instead of switching language in runtime and causing all components to re-render, we switch page and re-create i18n instance.

In the current example not all routes is behind $lang segment. It means some of them working one way some of them working another way.

Also lingui has deps extrator feature, which allows to split catalogs by route. Than using loader will be the only one way to load these catalogs:

$lang.home.tsx -> load -> home.po
$lang.users.tsx -> load -> users.po

@depsimon
Copy link
Contributor Author

I think that if you setup a new instance in the Page component, it'll be different from the context.i18n which is the one you'd use in the loaders/head functions for instance.

I'm not sure I understand why it's better to re-create a I18n instance rather than loading a catalog in the existing instance, is there a particular reason to that?

This route should be a parent of all routes.
In the current example not all routes is behind $lang segment. It means some of them working one way some of them working another way.

I don't want that for this example TBH. I just think there are many different strategies when it comes to how to structure your app in a i18n context, this is one valid way of doing it but I don't think it should be imposed on all users.

That's why it's available in a simple form as a part of the example template (cfr the $lang/content route). If there is a demand, maybe it'll be worth to create two/multiple examples with different strategies (multi domain, subdomain, directory, ...).

@timofei-iatsenko
Copy link
Collaborator

I think that if you setup a new instance in the Page component, it'll be different from the context.i18n which is the one you'd use in the loaders/head functions for instance.

This context.i18n should be avoided. And instance from loader used everywhere. It may seem convenient, but it introduces hidden state and coupling that doesn't work well in isomorphic apps. I explain more below.

I'm not sure I understand why it's better to re-create an I18n instance rather than loading a catalog in the existing instance, is there a particular reason to that?

From my experience with meta frameworks like Next.js — which TanStack Start is quite similar to — pages should be treated as stateless. In SPAs, it's common to share instances across the app, but that pattern doesn’t hold up in MPA or isomorphic scenarios.

Relying on shared runtime state (like a global i18n instance) can lead to subtle bugs that are difficult to reproduce. For example: “I opened page A via direct link, then navigated to page B — everything worked. Then I refreshed and it broke.” These issues usually stem from mismatches between client-side navigation and server-side rendering. They’re especially problematic when the app is hydrated with assumptions based on prior state.

That's why I strongly prefer initializing i18n explicitly per page. It removes hidden dependencies and makes the system more predictable and easier to debug — especially at scale.

I don't want that for this example TBH...

That's why it's available in a simple form as a part of the example template...

I think it’s risky to include approaches that are known to lead to architectural dead-ends — even as optional examples. If users see them in an official template, they will often assume it's a recommended or future-proof approach.

Yes, different strategies exist (subfolders, subdomains, multi-domain, etc.), and showing them in isolated, scoped examples could be helpful. But we should avoid including patterns that break down after just a few iterations — for example, when trying to add static generation, proper SEO.

If the example encourages a structure that later turns out to be incompatible with these features, users will understandably be frustrated. Even worse, they may blame the library itself: “I followed the official example and now it doesn’t work.” That’s what we should avoid.

@depsimon
Copy link
Contributor Author

I think that if you setup a new instance in the Page component, it'll be different from the context.i18n which is the one you'd use in the loaders/head functions for instance.

This context.i18n should be avoided. And instance from loader used everywhere. It may seem convenient, but it introduces hidden state and coupling that doesn't work well in isomorphic apps. I explain more below.

I don't think you can avoid the context.i18n, how would you localize content in the (nested or even sibling) loaders & meta without that context.i18n ?


PS: pinged you on Discord if you want to discuss things more easily than in a Github Discussion

@aarjithn
Copy link

aarjithn commented Jul 31, 2025

any recommendations on how to get this would be helpful, now that devinxi got released.

Also on topic of route path, my 2c is that it's the webSITES that do a path based approach, web APPs I think rarely does that which would be majority of Start users will be (I guess)

@smoothdvd
Copy link

any progress?

@jsefiani
Copy link

jsefiani commented Aug 17, 2025

Hey team! Great progress so far! Please let me know if you need any help with testing or anything else to move this PR forward.

Idea: it would be nice to have a recommended approach on how to access translation utils in the head function to translate meta tags like title and description.

FYI, this is what im currently doing but you don't seem to like/recommend this approach right? @timofei-iatsenko

export const Route = createFileRoute('/$locale/')({
  component: HomeScreen,
  loader: async ({ context }) => {
    return {
      title: context.i18n.t(
        `...`,
      ),
      description: context.i18n.t(
        `...`,
      ),
    }
  },
  head: ({ loaderData }) => {
    return {
      meta: [
        {
          title: loaderData?.title,
        },
        {
          description: loaderData?.description,
        },
      ],
    }
  },
})

@depsimon
Copy link
Contributor Author

@jsefiani If you have feedback about this PR it's more than welcome :)

I don't think there is a better way to translate metatags in the head function than what you're doing right now. It's also how you'd do it with content that comes from the DB.

@depsimon
Copy link
Contributor Author

any recommendations on how to get this would be helpful, now that devinxi got released.

Have you tried using the updated example from the PR in your project? It should work fine with devinxi.

Also on topic of route path, my 2c is that it's the webSITES that do a path based approach, web APPs I think rarely does that which would be majority of Start users will be (I guess)

Thanks for your input. That's also my feeling. Though it's not a bad idea to show users how to do it with Start & Lingui, but maybe in a separate example that focuses more around a content-website?

@eve0415
Copy link

eve0415 commented Sep 25, 2025

RC is out now and I don't have ideas to update for lingui

@depsimon
Copy link
Contributor Author

RC is out now and I don't have ideas to update for lingui

Let's wait for the RC to stabilize a bit, a lot of fixes are still going through.

Copilot AI review requested due to automatic review settings October 28, 2025 13:20
@vercel
Copy link

vercel bot commented Oct 28, 2025

@depsimon is attempting to deploy a commit to the Crowdin Team on Vercel.

A member of the Team first needs to authorize it.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR migrates the TanStack Start example from vinxi to vite-based architecture and updates to Tailwind CSS v4, aligning with TanStack Start's latest version changes.

Key changes:

  • Replaced vinxi build tooling with vite configuration
  • Updated to Tailwind CSS v4 with new @import syntax
  • Refactored i18n setup to work with new TanStack Start patterns using middleware and router context

Reviewed Changes

Copilot reviewed 32 out of 33 changed files in this pull request and generated no comments.

Show a summary per file
File Description
vite.config.ts New vite configuration replacing vinxi setup
tailwind.config.mjs Removed legacy Tailwind v3 configuration
src/styles/app.css Updated to Tailwind v4 @import syntax
src/server.ts New server entry point replacing ssr.tsx
src/client.tsx Updated hydration setup with StrictMode
src/router.tsx Added AppContext type and routerWithLingui plugin integration
src/modules/lingui/router-plugin.tsx New router plugin for i18n provider wrapping
src/modules/lingui/lingui-middleware.ts New middleware for server-side i18n setup
src/modules/lingui/i18n.ts Refactored dynamicActivate to accept i18n instance
src/modules/lingui/i18n.server.ts Simplified locale detection logic
src/routes/api/users.ts Migrated from createAPIFileRoute to createServerFileRoute
src/routes/api/users.$id.ts Added linguiMiddleware and updated to new API patterns
src/routes/__root.tsx Updated to createRootRouteWithContext and new locale update flow
src/routes/$lang/*.tsx New localized route files demonstrating i18n routing
package.json Updated dependencies to latest versions
app.config.ts Removed, configuration moved to vite.config.ts

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@depsimon
Copy link
Contributor Author

Had to update to RC at work, so I finally had the time to work on it.

I have pushed the changes to make it work with Tanstack Start RC.

Feedback welcome

@smoothdvd
Copy link

@depsimon there is no definition of initI18n in modules/lingui/i18n.ts

@depsimon
Copy link
Contributor Author

@depsimon there is no definition of initI18n in modules/lingui/i18n.ts

Fixed, thanks for reporting

@smoothdvd
Copy link

smoothdvd commented Nov 1, 2025

Type error in src/router.tsx

Screenshot 2025-11-01 at 3 33 45 PM

"@tanstack/react-router": "^1.134.4",
"@tanstack/react-start": "^1.134.7",

@depsimon
Copy link
Contributor Author

depsimon commented Nov 1, 2025

Type error in src/router.tsx
Screenshot 2025-11-01 at 3 33 45 PM

"@tanstack/react-router": "^1.134.4", "@tanstack/react-start": "^1.134.7",

I don't have any TS issue with the example. Updated to 1.34 and worked fine as well. Could it be your routerTree?

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.

6 participants