Temukan mengapa TanStack Start ada, evolusinya dari ekosistem React Router, dan cara membangun blog MDX siap produksi dengan manajemen query terintegrasi dan server functions.

Ekosistem JavaScript memiliki masalah: membangun aplikasi full-stack memerlukan penggabungan beberapa framework, masing-masing dengan filosofi, konvensi, dan kurva pembelajaran yang berbeda. Anda memilih framework frontend (React), router (React Router), library data fetching (TanStack Query), framework server (Express, Remix, Next.js), dan tiba-tiba Anda mengelola kontrak API, type safety lintas batas, dan kompleksitas deployment.
TanStack Start ada untuk mengatasi fragmentasi ini. Ini bukan hanya framework lain—ini adalah meta-framework full-stack yang opinionated dan type-safe yang dibangun di atas teknologi terbukti dari ekosistem TanStack. Dalam artikel ini, kami akan mengeksplorasi mengapa ia diciptakan, bagaimana ia berkembang, memahami fundamental inti, dan membangun blog berbasis MDX praktis untuk melihatnya beraksi.
Sebelum TanStack Start, membangun aplikasi React full-stack modern berarti membuat pilihan sulit:
Opsi 1: Frontend & Backend Terpisah
Opsi 2: Meta-Frameworks (Next.js, Remix)
Opsi 3: Full-Stack Frameworks (SvelteKit, Nuxt)
Isu inti: developer harus memilih antara type safety, developer experience, dan fleksibilitas arsitektur.
Pertimbangkan skenario tipikal: Anda membangun blog. Anda perlu:
Dengan frontend/backend terpisah, Anda akan menulis:
// Frontend
const { data, isLoading } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetch(`/api/posts?page=${page}`).then(r => r.json())
})
// Backend
app.get('/api/posts', (req, res) => {
const posts = db.query(...)
res.json(posts)
})Tipe data adalah unknown. Anda perlu memvalidasinya secara manual, menulis Zod schemas, atau menggunakan tools code generation. Ini adalah friction.
Tanner Linsley, kreator TanStack Query dan React Router, menyadari bahwa ekosistem TanStack memiliki semua pieces yang diperlukan untuk pengalaman full-stack yang kohesif. Alih-alih memaksa developer memilih antara frameworks, mengapa tidak membuat meta-framework yang:
TanStack Start diluncurkan sebagai penerus spiritual Remix, tetapi dengan DNA TanStack: minimal abstractions, maximum control.
React Router Era (2015-2020)
TanStack Query Era (2020-2022)
TanStack Start Era (2023-Present)
TanStack Start menggunakan file-based routing mirip dengan Next.js dan Remix. Routes didefinisikan oleh struktur file Anda:
app/
├── __root.tsx # Root layout
├── index.tsx # / route
├── blog/
│ ├── index.tsx # /blog route
│ └── $postId.tsx # /blog/:postId route
└── api/
└── posts.ts # Server functionRoutes secara otomatis ditemukan dan dikompilasi. Tidak perlu konfigurasi route manual.
Alih-alih membangun REST APIs, TanStack Start menggunakan server functions—fungsi TypeScript yang berjalan di server tetapi dipanggil dari client seolah-olah mereka lokal:
// app/api/posts.server.ts
export async function getPosts(page: number) {
const posts = await db.posts.findMany({
skip: (page - 1) * 10,
take: 10,
})
return posts
}Di client:
// app/blog/index.tsx
import { getPosts } from '../api/posts.server'
import { useQuery } from '@tanstack/react-query'
export default function BlogPage() {
const { data: posts } = useQuery({
queryKey: ['posts', page],
queryFn: () => getPosts(page)
})
return <div>{/* render posts */}</div>
}Mengapa ini powerful:
getPostsTanStack Query built-in, bukan bolted-on. Ini berarti:
useQuery dan useMutationconst { mutate: createPost } = useMutation({
mutationFn: (newPost) => createPostServer(newPost),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
}
})Routes dapat dirender di server, mengaktifkan:
// app/blog/$postId.tsx
export async function loader({ params }) {
return getPost(params.postId)
}
export default function PostPage() {
const post = useLoaderData()
return <article>{post.content}</article>
}TanStack Start menyediakan lifecycle hooks untuk authentication, logging, dan request/response transformation:
// app/__root.tsx
export const middleware = [authMiddleware, loggingMiddleware]
async function authMiddleware(req, res, next) {
const user = await verifyToken(req.headers.authorization)
req.user = user
next()
}Sekarang mari kita bangun sesuatu yang praktis: blog berbasis MDX menggunakan TanStack Start dengan Velite untuk content management dan Shiki untuk syntax highlighting.
blog-app/
├── app/
│ ├── __root.tsx
│ ├── index.tsx
│ ├── blog/
│ │ ├── index.tsx
│ │ └── $slug.tsx
│ └── api/
│ └── posts.server.ts
├── content/
│ └── posts/
│ ├── tanstack-start-guide.mdx
│ └── react-patterns.mdx
├── velite.config.ts
├── tsconfig.json
└── package.jsonVelite mengubah file MDX Anda menjadi data terstruktur. Berikut konfigurasinya:
import { defineConfig, s } from 'velite'
import { rehypeShiki } from '@shikijs/rehype'
export default defineConfig({
collections: {
posts: {
name: 'Post',
pattern: 'posts/**/*.mdx',
schema: s
.object({
title: s.string(),
excerpt: s.string(),
published_date: s.string().transform(v => new Date(v)),
updated_date: s.string().transform(v => new Date(v)),
tags: s.array(s.string()),
categories: s.array(s.string()),
content: s.mdx(),
slug: s.slug('title'),
})
.strict(),
transform: async (document) => ({
...document,
permalink: `/blog/${document.slug}`,
}),
},
},
mdx: {
rehypePlugins: [
[
rehypeShiki,
{
theme: 'github-dark',
fallbackLanguage: 'plaintext',
},
],
],
},
})Konfigurasi ini:
posts/**/*.mdximport { posts } from '@/.velite'
export async function getAllPosts() {
return posts
.filter(post => post.published)
.sort((a, b) =>
new Date(b.published_date).getTime() -
new Date(a.published_date).getTime()
)
}
export async function getPostBySlug(slug: string) {
const post = posts.find(p => p.slug === slug)
if (!post) {
throw new Error(`Post not found: ${slug}`)
}
return post
}
export async function getRelatedPosts(slug: string, limit: number = 3) {
const post = await getPostBySlug(slug)
return posts
.filter(p =>
p.slug !== slug &&
p.tags.some(tag => post.tags.includes(tag))
)
.slice(0, limit)
}import { useQuery } from '@tanstack/react-query'
import { getAllPosts } from '../api/posts.server'
import Link from '@tanstack/react-router'
export default function BlogPage() {
const { data: posts, isLoading } = useQuery({
queryKey: ['posts'],
queryFn: () => getAllPosts(),
})
if (isLoading) return <div>Loading posts...</div>
return (
<div className="max-w-4xl mx-auto py-12">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="space-y-8">
{posts?.map(post => (
<article
key={post.slug}
className="border-b pb-8 last:border-b-0"
>
<Link
to={`/blog/${post.slug}`}
className="hover:text-blue-600"
>
<h2 className="text-2xl font-semibold mb-2">
{post.title}
</h2>
</Link>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<time dateTime={post.published_date.toISOString()}>
{post.published_date.toLocaleDateString()}
</time>
<div className="flex gap-2">
{post.tags.map(tag => (
<span
key={tag}
className="bg-gray-100 px-2 py-1 rounded text-xs"
>
{tag}
</span>
))}
</div>
</div>
</article>
))}
</div>
</div>
)
}import { useParams } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { getPostBySlug, getRelatedPosts } from '../api/posts.server'
export default function PostPage() {
const { slug } = useParams({ from: '/blog/$slug' })
const { data: post, isLoading } = useQuery({
queryKey: ['post', slug],
queryFn: () => getPostBySlug(slug),
})
const { data: relatedPosts } = useQuery({
queryKey: ['relatedPosts', slug],
queryFn: () => getRelatedPosts(slug),
enabled: !!post,
})
if (isLoading) return <div>Loading post...</div>
if (!post) return <div>Post not found</div>
return (
<article className="max-w-3xl mx-auto py-12">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center justify-between text-gray-600 mb-6">
<time dateTime={post.published_date.toISOString()}>
{post.published_date.toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<div className="flex gap-2">
{post.tags.map(tag => (
<span
key={tag}
className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm"
>
{tag}
</span>
))}
</div>
</div>
<p className="text-lg text-gray-700">{post.excerpt}</p>
</header>
{/* MDX Content dengan Shiki syntax highlighting */}
<div className="prose prose-invert max-w-none mb-12">
{post.content}
</div>
{/* Related Posts */}
{relatedPosts && relatedPosts.length > 0 && (
<aside className="mt-12 pt-8 border-t">
<h3 className="text-2xl font-semibold mb-6">Post Terkait</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{relatedPosts.map(relatedPost => (
<a
key={relatedPost.slug}
href={`/blog/${relatedPost.slug}`}
className="p-4 border rounded-lg hover:shadow-lg transition"
>
<h4 className="font-semibold mb-2">{relatedPost.title}</h4>
<p className="text-sm text-gray-600">{relatedPost.excerpt}</p>
</a>
))}
</div>
</aside>
)}
</article>
)
}---
title: Memulai dengan TanStack Start
excerpt: Pelajari fundamental TanStack Start dan bangun aplikasi full-stack pertama Anda.
published_date: "2026-02-15"
updated_date: "2026-02-15"
tags:
- programming
- software-engineer
categories:
- Tutorials
---
## Pengenalan
TanStack Start adalah meta-framework full-stack yang dibangun di atas React Router dan TanStack Query.
## Instalasi
```bash
npm install @tanstack/start @tanstack/react-router @tanstack/react-query
```export default function HomePage() {
return <h1>Selamat datang di TanStack Start</h1>
}Itu saja! Route Anda siap.
## Kesalahan Umum & Pitfalls
### 1. Lupa Ekstensi `.server`
Server functions harus berakhir dengan `.server.ts` atau `.server.tsx`. Tanpanya, mereka akan di-bundle ke dalam kode client, mengekspos logika sensitif.
```ts
// ❌ Salah - akan dikirim ke browser
export async function getSecretKey() {
return process.env.SECRET_KEY
}
// ✅ Benar - tetap di server
// posts.server.ts
export async function getSecretKey() {
return process.env.SECRET_KEY
}Setelah membuat, mengupdate, atau menghapus data, Anda harus invalidate related queries:
// ❌ Salah - UI tidak akan update
const { mutate } = useMutation({
mutationFn: createPost,
})
// ✅ Benar - UI update secara otomatis
const { mutate } = useMutation({
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})Pendekatan file-based TanStack Start bisa membingungkan. Ingat:
.server.ts hanya berjalan di server.ts reguler berjalan di client dan server (hati-hati dengan imports)'use client' untuk code client-only// ❌ Salah - database code di client component
export default function Page() {
const posts = db.posts.findMany() // ❌ Berjalan di client!
return <div>{posts}</div>
}
// ✅ Benar - gunakan server function
export async function getPosts() {
return db.posts.findMany()
}
export default function Page() {
const { data: posts } = useQuery({
queryFn: () => getPosts()
})
return <div>{posts}</div>
}Dengan server functions, mudah untuk fetch semuanya. Gunakan pagination dan filtering:
// ❌ Salah - fetch semua posts setiap kali
export async function getPosts() {
return db.posts.findMany()
}
// ✅ Benar - paginated dan filtered
export async function getPosts(page: number = 1, limit: number = 10) {
return db.posts.findMany({
skip: (page - 1) * limit,
take: limit,
})
}Kelompokkan server functions terkait bersama:
app/
├── api/
│ ├── posts.server.ts # Post-related functions
│ ├── comments.server.ts # Comment-related functions
│ └── auth.server.ts # Auth-related functionsDefinisikan shared types di lokasi umum:
export interface Post {
id: string
title: string
content: string
published_date: Date
tags: string[]
}
export interface CreatePostInput {
title: string
content: string
tags: string[]
}Kemudian gunakan di server dan client code:
// app/api/posts.server.ts
import type { Post, CreatePostInput } from '../types'
export async function createPost(input: CreatePostInput): Promise<Post> {
return db.posts.create(input)
}export async function getPost(id: string) {
try {
const post = await db.posts.findUnique({ where: { id } })
if (!post) {
throw new Error('Post not found')
}
return post
} catch (error) {
console.error('Failed to fetch post:', error)
throw error
}
}Gunakan cache times yang sesuai berdasarkan volatilitas data:
// Static content - cache untuk 1 jam
useQuery({
queryKey: ['posts'],
queryFn: getPosts,
staleTime: 1000 * 60 * 60,
})
// User-specific data - cache untuk 5 menit
useQuery({
queryKey: ['user', userId],
queryFn: () => getUser(userId),
staleTime: 1000 * 60 * 5,
})
// Real-time data - tidak ada caching
useQuery({
queryKey: ['notifications'],
queryFn: getNotifications,
staleTime: 0,
})Gunakan dynamic imports untuk heavy components:
import { lazy, Suspense } from 'react'
const HeavyEditor = lazy(() => import('./HeavyEditor'))
export default function Page() {
return (
<Suspense fallback={<div>Loading editor...</div>}>
<HeavyEditor />
</Suspense>
)
}Jika Anda membangun situs purely static (blog, dokumentasi), Next.js dengan static generation mungkin lebih sederhana.
Untuk aplikasi yang sangat sederhana, overhead TanStack Start mungkin tidak justified. Pertimbangkan framework yang lebih sederhana.
Meskipun TanStack Start mendukung SSR, jika SEO kritis dan kompleks, Next.js memiliki solusi yang lebih mature.
TanStack Start memerlukan solid React knowledge. Jika tim Anda baru di React, pertimbangkan framework yang lebih beginner-friendly.
TanStack Start menyelesaikan masalah nyata: fragmentasi full-stack JavaScript development. Dengan menggabungkan React Router, TanStack Query, dan server functions, ia menyediakan pengalaman development yang kohesif dan type-safe tanpa mengorbankan fleksibilitas.
Contoh blog yang kami bangun mendemonstrasikan pola inti:
Mulai kecil, bangun secara incremental, dan leverage maturity ekosistem TanStack. Learning curve-nya worth it.
Langkah selanjutnya:
Full-stack React future sudah tiba. Selamat datang di TanStack Start.