When we started building Nativly, we didn’t set out to “add translation.”
We were trying to solve something more fundamental:
Why should language decide who gets to participate in an online community?
Most community platforms assume English. If you don’t speak it comfortably, you adapt — or you leave.
We didn’t want adaptation.
We wanted inclusion by default.
This is the story of how we built a real-time multilingual community platform using:
- Next.js
- Supabase
- Lingo.dev SDK And how we made translation feel invisible.
The Problem We Faced
Static UI translation is easy.
You use i18n.
You add JSON files.
You translate buttons.
But Nativly isn’t just UI.
It’s:
- Posts
- Comments
- User bios
- Community discussions All user-generated.
And here’s the real challenge:
If a Hindi user writes a post,
- a Spanish user should read it in Spanish.
- An English user should see it in English.
- A Tamil user should see it in Tamil.
Same content Different viewer Real-time
Pre-translating every post into 20 languages? Not scalable.
Storing 20 columns per post? Messy.
Translating on the client? Unsafe.
We needed something smarter.
Our Core Idea: Store Once, Translate on View
Instead of storing translations in the database, we made a design decision that changed everything.
Store the original content only. Translate at render time based on the viewer.
Our Architecture (Simple but Powerful)
Here’s how Nativly works internally:
- User writes post in their native language.
- We store:
- Original text
- Source language
- When another user views the post:
- We check their preferred language.
- If it differs → we translate in real time using Lingo.dev.
- We cache the result.
- We render translated content.
That’s it.
No duplicated database rows.
No bloated schema.
No language-specific columns.
Just intelligent translation at the edge of interaction.
Our Database Design (Supabase)
Our posts table looks like this:
create table posts (
id uuid primary key default uuid_generate_v4(),
user_id uuid references profiles(id),
original_text text not null,
source_language varchar(10) not null,
created_at timestamp with time zone default now()
);
That’s all.
We don’t store text_hi, text_bn, text_ta.
Because translation is not storage.
It’s transformation.
Integrating Lingo.dev SDK (Server-Side)
We integrated Lingo.dev in our Next.js backend layer (Route Handlers).
We do not call it from the client. Why?
- Keeps API key secure
- Allows caching logic
- Reduces abuse
- Keeps translation consistent
Step 1: Installing SDK
npm install @lingo-dev/sdk
Step 2: Creating a Translation Service /lib/lingo.ts
import Lingo from "@lingo-dev/sdk";
const lingo = new Lingo({
apiKey: process.env.LINGO_API_KEY!,
});
export async function translateText(
text: string,
sourceLanguage: string,
targetLanguage: string
) {
if (sourceLanguage === targetLanguage) {
return text;
}
const response = await lingo.translate({
text,
sourceLanguage,
targetLanguage,
});
return response.translatedText;
}
Simple.
No overengineering.
Real-Time Translation in Our API Route
When fetching posts for a user:
/app/api/feed/route.ts
import { translateText } from "@/lib/lingo";
import { createClient } from "@/lib/supabase/server";
export async function GET() {
const supabase = createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const { data: profile } = await supabase
.from("profiles")
.select("preferred_language")
.eq("id", user?.id)
.single();
const viewerLanguage = profile?.preferred_language || "en";
const { data: posts } = await supabase
.from("posts")
.select("*")
.order("created_at", { ascending: false });
const translatedPosts = await Promise.all(
posts.map(async (post) => {
const translated = await translateText(
post.original_text,
post.source_language,
viewerLanguage
);
return {
...post,
display_text: translated,
};
})
);
return Response.json(translatedPosts);
}
Notice what we’re doing:
- We don’t mutate stored content.
- We compute display_text dynamically.
- Translation depends entirely on who is viewing. That’s the key difference.
Making It Feel Instant (Caching Strategy)
Real-time translation is powerful.
But it can be expensive and slow if done naively.
So we added caching.
Our Strategy
If:
- Post A (Hindi)
- Viewed by 10 English users
We shouldn’t translate it 10 times.
We cache translations like this:
translation_cache:
post_id
target_language
translated_text
Before calling Lingo.dev, we check:
const cached = await supabase
.from("translation_cache")
.select("translated_text")
.eq("post_id", post.id)
.eq("target_language", viewerLanguage)
.single();
If exists → return cached.
If not → call Lingo → store → return.
This reduced API calls drastically.
Why We Didn’t Pre-Translate
We tested pre-translation into 5 languages at post creation.
Problems:
- Increased post creation latency.
- Wasteful if no one reads in certain languages.
- Hard to scale beyond fixed languages. Translation should happen when needed, not before.
UI Integration (Next.js)
Frontend is simple.
We just render:
<p>{post.display_text}</p>
Users never see:
- loading spinners
- raw language switches
- language mismatch
It just works.
And that’s the goal.
Handling User Language Preferences
Our profiles table:
preferred_language varchar(10) default 'en'
Users can select:
- English
- Hindi
- Dutch
- Japanese
- French
Changing this setting instantly changes how the entire feed renders.
No reload hacks.
No separate routes.
Just dynamic translation.
What Lingo.dev made possible
Without Lingo:
We would need:
- Manual translation pipelines
- Separate language services
- Heavy infra
Instead:
We get:
- Clean SDK
- Server-side integration
- High-quality translations
- Scalable API
It let us focus on product,
not language mechanics.
What We Learned
1.Translation is a Product Feature, Not a Utility
When users read content in their language:
- They comment more.
- They stay longer.
- They engage deeper.
It changes their entire behavior.
2.Server-Side Translation is Cleaner
Doing translation inside API routes:
- Keeps logic centralized
- Avoids duplicated client calls
- Makes caching easier
- Protects your SDK key
3.Real-Time > Static for Communities
Static UI translation is necessary.
But it doesn’t solve community inclusion.
User-generated content must be dynamic.
That’s where Lingo.dev became essential for us.
Performance Considerations
We optimized:
- Only translate when languages differ
- Cache aggressively
- Batch translation calls where possible
- Use Promise.all for concurrency
Result:
Feed feels native.
Even when languages are mixed.
What Makes This Different
Many multilingual apps:
- Translate UI
- Ignore user content
Nativly:
- Translates conversations.
- Translates community interaction.
- Makes cross-language dialogue natural.
It doesn’t just display languages.
It bridges them.
Where We’re Taking This Next
Future improvements:
- Translation confidence scoring
- Context-aware translation (community-specific tone)
- Real-time comment streaming with translation
- Language auto-detection for posts
But the foundation is stable.
And it’s simple.
Final Thoughts
We didn’t build translation because it was trendy.
We built it because communities shouldn’t fragment along language lines.
With:
- Next.js for structure
- Supabase for data
- Lingo.dev for intelligent real-time translation We turned a local community idea into a borderless platform.
Same database.
Same post.
Different language.
Different experience.
That’s the power Lingo.dev brings to the table.
If this article helped you, share it with your friends or someone who's building cool stuff.
Top comments (0)