Your buttons, nav and empty states are in German. Your product names, categories and descriptions are still in English. That is not a bug in your i18n setup, it is the boundary of it. globalize.now is AI-powered localization infrastructure: it extracts hardcoded strings from your codebase, generates the keys and locale catalogs, and syncs them on every Git push. It reads code, not rows, and the content your users came for lives in rows.
Fixing this is a schema decision, not a tooling decision. Here is the version that holds up.
Why is my Lovable app's UI translated but the database content still in English?
Because a string extractor can only see what is written in your source files.
When you localize a Lovable app, a scanner walks your components and pulls out every literal it finds: "Add to cart", "No results", "Sign in". Those become message IDs in a catalog, and the catalog gets translated and committed. That pipeline is deterministic and it covers the interface completely.
It cannot cover a row. products.name = 'Trail Runner' was never in a .tsxfile. It arrived through a form, a seed script, or an import, and it lives in Postgres: Lovable Cloud's built-in backend, which runs on Supabase, or a Supabase project you connected yourself. No code scanner will ever read it, and no amount of re-running extraction will change that.
So you end up with a translated shell around English content. The nav says Warenkorb, the product inside it says Trail Runner, lightweight trail shoe for wet conditions. Users notice immediately, and it reads worse than an untranslated app, because it looks abandoned halfway.
What counts as database content in a Lovable app?
Anything a user or an admin can change without a deploy. In practice, on a typical Lovable build, that is:
- Product, plan or listing names and descriptions
- Category, tag and status labels rendered from a lookup table
- Marketing copy stored in a
pagesorsectionstable - Email and notification templates
- Enum-ish values you display directly, like
status = 'pending_review' - User-generated content, which is usually left in its original language on purpose
The last one matters. Do not translate user-generated content by default. A review written in Spanish should stay in Spanish. The set you actually localize is the content you author and ship.
Everything else, the chrome around that content, stays in your catalogs. The two sets should never overlap. If a label appears in both a lookup table and a hardcoded string, pick one home for it and delete the other, or you will ship two different German words for the same concept.
How do you store translations for database content in Supabase?
Use a separate translation table keyed on the row id and a locale column.
create table product_translations (
product_id uuid not null references products(id) on delete cascade,
locale text not null,
name text not null,
description text,
primary key (product_id, locale)
);
create index product_translations_locale_idx on product_translations (locale);Adding Japanese is now an insert, not a migration. Row-level security policies attach to one table instead of a growing set of columns, and a missing translation is a missing row, which is easy to query for and easy to alert on.
The alternative is a JSONB column per translatable field:
alter table products
add column name_i18n jsonb not null default '{}'::jsonb;
-- { "en": "Trail Runner", "de": "Trail Runner", "fr": "Coureur de sentier" }This is faster to prototype and fine for small, mostly static content. It costs you per-language constraints, makes partial translations awkward to audit, and gets expensive to index once you are filtering or sorting on the localized value.
The third pattern, a column per language (name_en, name_de, name_fr), is the one to avoid. Every new language is a schema migration on every table, and on a Lovable project that means asking the agent to alter production tables repeatedly. If you want the pattern written up in more depth, the public pg_i18n Postgres extension implements the translation-table approach as views with fallback built in, and is worth reading before you commit to a shape.
Which pattern should you pick?
Translation table, unless the content is tiny and frozen.
Pick JSONB only when you have fewer than a handful of translatable fields, no need to filter or sort on them, and no plan to let anyone but you edit them. Everything else, anything with an admin UI, anything you will add languages to, anything with more than a few hundred rows, wants the table.
How do you query the right language at render time?
With an explicit fallback, in the database, so the app never has to think about it.
create or replace function products_for_locale(p_locale text)
returns table (id uuid, slug text, name text, description text)
language sql stable as $$
select
p.id,
p.slug,
coalesce(t.name, base.name) as name,
coalesce(t.description, base.description) as description
from products p
left join product_translations base
on base.product_id = p.id and base.locale = 'en'
left join product_translations t
on t.product_id = p.id and t.locale = p_locale;
$$;Then the call site carries a locale and nothing else:
const { data: products } = await supabase
.rpc('products_for_locale', { p_locale: locale });Two rules make this survive contact with real data. First, coalesce to the source language, never to an empty string: a half-translated catalogue should show English, not blank cards. Second, the locale you pass must be the same value your route segment uses and the same value your UI catalogs are keyed on. One locale list, three consumers: router, catalogs, database.
That last point is where most Lovable projects drift. The router knows about de, the catalogs were built for de-DE, and the database has rows tagged german. Pick the exact set of BCP 47 tags your locale routes use and make everything else conform to it. If you have not set those routes up yet, the per-stack walkthroughs cover it for both of Lovable's generated stacks: the Vite SPA build and the TanStack Start SSR default.
Why not just let a runtime widget translate the database text?
Because it works on the rendered page, not on your data, and that difference shows up in three places.
A JavaScript translation widget reads whatever text lands in the DOM and swaps it after the fact. That genuinely does catch database rows: it is the one thing widgets do that catalogs do not, and it is why they feel like a complete solution at first.
The costs are structural. Translated rows exist only in the browser session, so search engines crawling the page see your source language; Weglot's own help centre states that its JavaScript integration does not provide SEO benefits, because those translations are rendered client-side. Corrections live in the vendor's dashboard rather than your database, so the real value of a field is split across two systems. And the swap happens after paint, which is why translated apps built this way flash English on every navigation.
None of that is a reason to dismiss the approach outright for an internal tool. It is a reason not to build a public, indexable, multilingual product on it.
How do the two layers stay in sync?
By sharing one locale list and one trigger.
The code layer is automatic. Add the lovable-i18n skill in your Lovable workspace and prompt Lovable to set up i18n, or install it locally with:
npx skills add globalize-now/globalize-skillsFrom then on, extraction runs and a translation pull request opens on every push: no dashboard step, no manual pass over locale files. That is the same loop described in why Lovable translations break on every push.
The data layer is yours, and it needs one deliberate hook: whenever you add a locale, the same list that drives your routes and catalogs should drive a backfill into your translation table. On a Lovable project the practical version is a seed function you ask the agent to write once, that reads the locale list and inserts missing rows against the source-language values. Missing rows then get filled, not invented, and coalesce covers the gap in the meantime.
Pricing does not fight you here either. The Starter plan is €20 per month per workspace and includes €20 of translation credit (about 200,000 words) a month, with usage above that at the published per-character rate. There are no per-seat charges and no per-language charges, so adding the fifth language costs the characters it costs and nothing else. Signup comes with a €5 translation credit and no card. More on how this fits an agent-built stack on the vibe coders and developers pages.
What breaks when a localization vendor shuts down?
Whatever was stored on their servers, and nothing that was stored on yours.
This is not hypothetical this month. Lovalingo, the runtime translation layer many Lovable builders adopted, closes on 31 August 2026 at 23:59 CEST, confirmed on its own service-closure page, with new projects and subscriptions already stopped and preservation exports being prepared for active customers. Its own guidance is the interesting part: it tells customers to implement project-owned internationalization alongside it, and to remove it only after the replacement passes production checks in a live environment.
That is the whole argument in one sentence from the vendor whose exit proves it. A committed catalog and a product_translationstable are both things you own; they do not have a service status. A managed runtime layer is a dependency that renders your product's text, and dependencies end.
If you are migrating off a widget right now, do the data layer first. The UI strings will be re-extracted from your code in minutes. The database translations, if they only ever existed inside a vendor's dashboard, will not come back. Our Lovalingo comparison covers what to check before you remove anything.
The code layer is the part you should not be hand-managing. globalize.now extracts your strings, generates the catalogs and opens a translation pull request on every push, which leaves you free to design the data layer once and forget it.
globalize.now turns hardcoded app copy into translation-ready locale files and keeps them updated as you ship.
Try globalize.now free