What is Nuxt, and when should you choose it?
Nuxt is an open-source web application framework built on Vue. Vue gives you its component and reactivity model; Nuxt adds file-based routing, server-side rendering (SSR), data-fetching patterns, auto-imports and a server engine called Nitro around it. That lets you write both the pages users see and the small API endpoints those pages talk to in one project. As of 6 October 2026, when I wrote this guide, the current stable release is Nuxt 4.5.2, published on 5 August 2026, running on Nitro 2.13.4. Nuxt 3 reached end of life on 31 July 2026, so a new project should start with Nuxt 4.
The strongest reason to choose Nuxt is a project where the first response needs to arrive as meaningful HTML: content sites, catalogs, blogs and documentation, product screens that should be found in search. Sharing the same types and validation rules between server and client, and managing both the interface and the API in one codebase, also make Nuxt attractive. On the other hand, a small admin panel seen only by signed-in users, a widget embedded in someone else's page, or a single-screen tool with no SEO expectations may not need the runtime and configuration that Nuxt brings. The right question is not “which framework is best” but “where, and when, should this project's first response be produced?”
Throughout this guide we will grow a single example project: the Anadolu Product Guide. It is a small catalog that lists handmade products from local makers, supports search, gives each product its own detail page and publishes in Turkish and English. We will start with products held in memory; let me say up front that this is a deliberate simplification, and that real stock and pricing belong in a database or behind a trusted service. SSR is on by default in Nuxt; the setting below simply makes that behavior visible.
01export default defineNuxtConfig({02 ssr: true,03})Setup and the Nuxt 4 project structure
The Nuxt 4 installation guide requires Node.js 22 or newer and recommends an even-numbered release such as 22 or 24, preferably the active LTS. Check your version with node -v first, then create the project with the official starter and open the development server. The -o flag opens the browser automatically.
01npm create nuxt@latest anadolu-urun-rehberi02cd anadolu-urun-rehberi03npm run dev -- -oThe most visible change in Nuxt 4 is the folder layout. Everything that belongs to the Vue application now lives in the app/ folder by default: pages, components, layouts, composables, middleware and plugins. Server code stays in the root server/ folder. The root shared/ folder is for code that both the Vue app and the Nitro server can use, such as a product type or a price formatting function needed on both sides. public/ serves static files as they are. Projects with the Nuxt 3 layout are detected automatically, so migration is not mandatory; but keeping this separation in a new project shows at a glance which code may reach the browser and which must stay on the server.
Think of this separation as a small security boundary. A file under server/ can read a database password; a component under app/ ends up in the bundle sent to the browser. Accidentally moving a secret key into a component means putting it into a JavaScript file anyone can download. Only put code into shared/ that can run safely on both sides and carries no secrets.
Pages, file-based routing and layouts
In Nuxt you don't write a routing table; file names under app/pages/ become addresses. pages/index.vue creates the home page, pages/products/index.vue the product list, and pages/products/[slug].vue a detail page for every product, using the parameter in square brackets. Use NuxtLink to move between pages: it renders the correct <a> tag and can prefetch the target page's code when the link becomes visible. The root file app.vue says where to draw the page and the layout that wraps it.
01<template>02 <NuxtLayout>03 <NuxtPage />04 </NuxtLayout>05</template>Layouts are the frame shared by several pages: the top menu, the footer, common spacing. app/layouts/default.vue is the default frame, and the page's content is placed where <slot /> sits. If a page needs a different layout, you declare it inside the page with definePageMeta, so sections like an admin panel can use their own plain frame.
01<template>02 <div>03 <header><NuxtLink to="/">Anadolu Ürün Rehberi</NuxtLink></header>04 <main><slot /></main>05 </div>06</template>01<script setup lang="ts">02const route = useRoute()03 04definePageMeta({05 layout: 'default',06})07</script>08 09<template>10 <article>11 <h1>Ürün detayı</h1>12 <p>Slug: {{ route.params.slug }}</p>13 </article>14</template>There is a small but important detail here: route.params.slug is a value that comes from the user. Showing it on the page is fine, because Vue templates escape text automatically. But when we add it to an API address we will encode it with encodeURIComponent and always validate it on the server. Convenient routing does not mean the input can be trusted.
Data fetching: useFetch, useAsyncData and $fetch
Nuxt's data-fetching layer is its most misunderstood, yet most valuable, part. When a page is rendered on the server, data you fetch with useFetch or useAsyncData is sent to the browser alongside the HTML in a bundle called the “payload”. While the browser brings the page to life through hydration, the call with the same key reads that payload and does not repeat the initial request. If you call $fetch directly in a component's setup, that hand-off does not happen: the same request can run once on the server and again in the browser. The rule is simple: use the composables for data the page needs when it opens, and $fetch for actions triggered when the user presses a button.
01<script setup lang="ts">02interface Product {03 id: number04 slug: string05 name: string06 price: number07}08 09const { data: products, status, error, refresh } = await useFetch<Product[]>('/api/products', {10 key: 'catalog:products',11 pick: ['id', 'slug', 'name', 'price'],12 default: () => [],13})14 15async function sendInquiry(productSlug: string) {16 await $fetch('/api/inquiries', {17 method: 'POST',18 body: { productSlug },19 })20}21</script>22 23<template>24 <section>25 <p v-if="status === 'pending'">Ürünler yükleniyor…</p>26 <p v-else-if="error" role="alert">Ürünler alınamadı.</p>27 <ul v-else>28 <li v-for="product in products" :key="product.id">29 <NuxtLink :to="`/products/${product.slug}`">{{ product.name }}</NuxtLink>30 <span>{{ product.price }} ₺</span>31 <button @click="sendInquiry(product.slug)">Bilgi iste</button>32 </li>33 </ul>34 <button @click="refresh()">Yenile</button>35 </section>36</template>This example contains a few deliberate choices. We set an explicit key, because in Nuxt 4 calls that share a key also share data, error and status; that is why options such as pick, transform and default must stay consistent across same-key calls. pick keeps only the fields the list needs in the payload and shrinks the data embedded in the HTML; it does not, however, shrink the response received from the server. In Nuxt 4, data and error start as undefined and data is shallowly reactive by default, so providing an empty array with default keeps the template simple. In the template, we show loading through status and failure through error as separate states.
If you use a CMS client or an async function of your own, useAsyncData is the better fit. On a detail page, it is critical that the key is specific to the product: a fixed key can let one product's data leak into another product's page on prerendered pages. Generate the key with a function tied to the route parameter.
01const route = useRoute()02const slug = computed(() => String(route.params.slug))03const { data: product, error } = await useAsyncData(04 () => `catalog:product:${slug.value}`,05 () => $fetch(`/api/products/${encodeURIComponent(slug.value)}`),06)For non-critical data that the page does not need to wait for, you can defer the request to the browser. The server: false option means the request starts after hydration finishes; showing a loading state based on status during that time gives the user honest feedback.
01const { data: reviews, status } = useLazyFetch('/api/reviews', {02 server: false,03 default: () => [],04})A Nitro API with runtime validation
Nitro runs the server side of Nuxt. Every file you place under server/api/ becomes an API endpoint, and suffixes in the file name such as .get or .post decide which HTTP method it answers. In our example we keep the product data in server/utils/products.ts and serve the list and the detail from two separate endpoints. The #server alias arrived in Nuxt 4.3 and can only be used in server code, which makes it harder for a server module to slip into the browser bundle by mistake.
01export const products = [02 { id: 1, slug: 'seramik-kupa', name: 'Seramik kupa', price: 420, description: 'El yapımı seramik kupa.' },03 { id: 2, slug: 'dokuma-canta', name: 'Dokuma çanta', price: 890, description: 'Yerel dokumadan çanta.' },04]TypeScript types are erased at build time; they prove nothing about the type of a query parameter arriving from the internet. That is why the search endpoint validates the incoming q parameter with zod at runtime and returns a clear 400 response for invalid input. Using toLocaleLowerCase('tr') in the search matters for Turkish: it lowercases capital “I” to “ı” and capital “İ” to “i”, so a search for “Iğdır” returns the right result.
01import { z } from 'zod'02import { products } from '#server/utils/products'03 04const querySchema = z.object({05 q: z.string().trim().max(80).optional(),06})07 08export default defineEventHandler((event) => {09 const parsed = querySchema.safeParse(getQuery(event))10 if (!parsed.success) {11 throw createError({ statusCode: 400, statusMessage: 'Geçersiz arama parametresi' })12 }13 14 const q = parsed.data.q?.toLocaleLowerCase('tr')15 return q16 ? products.filter(product => product.name.toLocaleLowerCase('tr').includes(q))17 : products18})01import { products } from '#server/utils/products'02 03export default defineEventHandler((event) => {04 const slug = getRouterParam(event, 'slug')05 const product = products.find(item => item.slug === slug)06 if (!product) {07 throw createError({ statusCode: 404, statusMessage: 'Ürün bulunamadı' })08 }09 return product10})The endpoint that runs when the user presses “Request info” validates the body. This example does not store the inquiry anywhere; in a real project you must write it to a database or put it on a queue before responding. Keep secret keys out of the code and in environment variables through runtimeConfig, and never place them under runtimeConfig.public, because that section is sent to the browser.
01import { z } from 'zod'02 03const inquirySchema = z.object({04 productSlug: z.string().min(1).max(120),05})06 07export default defineEventHandler(async (event) => {08 const parsed = inquirySchema.safeParse(await readBody(event))09 if (!parsed.success) {10 throw createError({ statusCode: 400, statusMessage: 'Geçersiz talep' })11 }12 13 return { received: true, productSlug: parsed.data.productSlug }14})Rendering modes and hybrid rendering with routeRules
Nuxt's real strength is that you can decide how each page is produced. With SSR, HTML is generated on the server for every request: data is fresh, but every request means work on the server. With SSG, or prerendering, the page is generated at build time: hosting is very simple, but content changes need a rebuild. In SPA mode (ssr: false), an empty application shell is sent to the browser and the interface is drawn there; it is the weakest option for search engines but enough for private panels. Hybrid rendering combines these modes per address.
A sensible mix for our catalog: the home page rarely changes, so generate it at build time; serve product pages with a one-hour cache using “stale-while-revalidate” (SWR); and run the admin panel only in the browser. With SWR, the visitor receives the cached page immediately while a new version is prepared in the background. The price is that an old price may be visible for a short time after it changes; whether that is acceptable for your product is your decision. Remember too that ssr: false changes only how a route renders; it does not protect the admin address or its API, and authentication and authorization must always be enforced on the server.
01export default defineNuxtConfig({02 routeRules: {03 '/': { prerender: true },04 '/products/**': { swr: 3600 },05 '/admin/**': { ssr: false },06 },07})Keep two warnings in mind. First, never put personalized responses (a cart, account details) into a shared cache; SWR serves everyone the same page. Second, these hybrid rules do not work in the fully static output produced by nuxt generate; they need a running Nitro server. The isr rule doesn't behave the same on every platform either: the Nuxt docs name Vercel and Netlify for ISR with CDN caching. If you want a fully static site, make sure dynamic product addresses are discovered or listed at build time.
A rendering mode is a product decision, not a technology preference: for every address, answer “how fresh must it be, and how cheaply should it be served?”
SEO and Turkish/English content
SSR gives search engines meaningful HTML, but you still need to set the title, description and language tags correctly. Each product page should take its own title and description from the loaded data. useSeoMeta accepts reactive values and writes them into the tags escaped; show a meaningful default even when the product is not found.
01const route = useRoute()02const { data: product } = await useFetch<{ name: string; description: string }>(03 () => `/api/products/${encodeURIComponent(String(route.params.slug))}`,04)05const title = computed(() => product.value?.name ?? 'Ürün bulunamadı')06const description = computed(() => product.value?.description ?? 'Anadolu Ürün Rehberi')07useSeoMeta({ title, description, ogTitle: title, ogDescription: description })For bilingual publishing, add the official i18n module and the sitemap module. The prefix_except_default strategy produces Turkish addresses without a prefix and English addresses with the /en/ prefix. It is important to put full language tags such as tr-TR and en-US in the language field and to provide the production domain through baseUrl; otherwise hreflang and canonical links cannot be generated as absolute addresses.
01npx nuxi@latest module add i18n02npx nuxi@latest module add sitemap01export default defineNuxtConfig({02 modules: ['@nuxtjs/i18n', '@nuxtjs/sitemap'],03 i18n: {04 baseUrl: 'https://katalog.example',05 defaultLocale: 'tr',06 strategy: 'prefix_except_default',07 locales: [08 { code: 'tr', language: 'tr-TR', file: 'tr.json' },09 { code: 'en', language: 'en-US', file: 'en.json' },10 ],11 },12})01{ "catalog": { "title": "Ürünler", "empty": "Ürün bulunamadı" } }01{ "catalog": { "title": "Products", "empty": "No products found" } }01<script setup lang="ts">02const localeHead = useLocaleHead({ seo: true })03 04useHead(() => ({05 htmlAttrs: localeHead.value.htmlAttrs ?? {},06 link: localeHead.value.link ?? [],07 meta: localeHead.value.meta ?? [],08}))09</script>10 11<template><NuxtLayout><NuxtPage /></NuxtLayout></template>There is an important boundary here: the i18n module carries addresses, interface texts and SEO tags into two languages, but it does not translate product data by itself. So far our products have been Turkish only. For a truly bilingual catalog, keep each product's name and description in both languages and have the API return the requested language. Validate the language parameter like any other input.
01export const products = [02 {03 id: 1,04 slug: 'seramik-kupa',05 price: 420,06 name: { tr: 'Seramik kupa', en: 'Ceramic mug' },07 description: { tr: 'El yapımı seramik kupa.', en: 'Handmade ceramic mug.' },08 },09 {10 id: 2,11 slug: 'dokuma-canta',12 price: 890,13 name: { tr: 'Dokuma çanta', en: 'Woven bag' },14 description: { tr: 'Yerel dokumadan çanta.', en: 'A bag made from local weaving.' },15 },16]The list endpoint now validates both the search and the language, and searches the product name in the requested language. The detail endpoint validates the same language parameter and returns the product's name and description in that language. These two files replace the single-language versions from section 5.
01import { z } from 'zod'02import { products } from '#server/utils/products'03 04const querySchema = z.object({05 q: z.string().trim().max(80).optional(),06 locale: z.enum(['tr', 'en']).default('tr'),07})08 09export default defineEventHandler((event) => {10 const parsed = querySchema.safeParse(getQuery(event))11 if (!parsed.success) {12 throw createError({ statusCode: 400, statusMessage: 'Geçersiz arama parametresi' })13 }14 15 const { q, locale } = parsed.data16 const needle = q?.toLocaleLowerCase(locale)17 return products18 .map(product => ({19 id: product.id,20 slug: product.slug,21 price: product.price,22 name: product.name[locale],23 description: product.description[locale],24 }))25 .filter(product => !needle || product.name.toLocaleLowerCase(locale).includes(needle))26})01import { z } from 'zod'02import { products } from '#server/utils/products'03 04const querySchema = z.object({05 locale: z.enum(['tr', 'en']).default('tr'),06})07 08export default defineEventHandler((event) => {09 const parsed = querySchema.safeParse(getQuery(event))10 if (!parsed.success) {11 throw createError({ statusCode: 400, statusMessage: 'Geçersiz dil' })12 }13 14 const { locale } = parsed.data15 const product = products.find(item => item.slug === getRouterParam(event, 'slug'))16 if (!product) {17 throw createError({ statusCode: 404, statusMessage: 'Ürün bulunamadı' })18 }19 return { ...product, name: product.name[locale], description: product.description[locale] }20})On the pages, add the active language to the query. When the reactive value inside query changes, useFetch repeats the request, so the list and the detail arrive in the new language when the user switches. The SEO tags on the detail page are now generated from the product data in the selected language.
01const { locale } = useI18n()02const { data: products } = await useFetch('/api/products', {03 query: { locale },04 default: () => [],05})01const route = useRoute()02const { locale, t } = useI18n()03const { data: product } = await useFetch<{ name: string; description: string }>(04 () => `/api/products/${encodeURIComponent(String(route.params.slug))}`,05 { query: { locale } },06)07const title = computed(() => product.value?.name ?? t('catalog.empty'))08const description = computed(() => product.value?.description ?? t('catalog.title'))09useSeoMeta({ title, description, ogTitle: title, ogDescription: description })The sitemap module finds static pages on its own, but it cannot know which products make up dynamic addresses such as /products/[slug]. Give it the product addresses through a source endpoint. _i18nTransform: true makes every address enter the sitemap with its Turkish and English alternatives.
01import { products } from '#server/utils/products'02 03export default defineSitemapEventHandler(() => {04 return products.map(product => ({05 loc: `/products/${product.slug}`,06 _i18nTransform: true,07 }))08})01export default defineNuxtConfig({02 sitemap: {03 sources: ['/api/__sitemap__/urls'],04 },05})Don't assume the work is done once it is configured; check the output. Open the source of a product page in a production build and confirm that the two languages point to each other with hreflang, that the canonical address is correct and that product addresses in both languages appear in the sitemap. The free hreflang generator and sitemap validator tools on my site can make this check easier.
Performance and testing
On catalog sites, images are usually the heaviest load. The <NuxtImg> component of the @nuxt/image module prevents layout shift by declaring width and height, picks a suitable size for the screen with sizes, and makes it easy to defer off-screen images with loading="lazy". Remember that you need to install the module before using the component.
01<template>02 <article>03 <NuxtImg04 :src="`/images/${product.slug}.webp`"05 :alt="product.name"06 width="720"07 height="540"08 sizes="100vw sm:50vw lg:33vw"09 loading="lazy"10 />11 <h2>{{ product.name }}</h2>12 </article>13</template>14 15<script setup lang="ts">16defineProps<{ product: { slug: string; name: string } }>()17</script>
The JavaScript side needs similar discipline. Load components that are not needed for the page's first view with the Lazy prefix and behind a condition; for example, download the reviews panel only when the user asks for it. Limit the data you put into the payload with pick, because that data is embedded in the HTML and increases the page's initial weight.
01<script setup lang="ts">02const showReviews = ref(false)03</script>04 05<template>06 <button @click="showReviews = true">Yorumları göster</button>07 <LazyReviewPanel v-if="showReviews" />08</template>On the testing side, the official @nuxt/test-utils package lets you start your application as a real server and test the page HTML. The starter template does not install test tooling, so add the packages as development dependencies first. For tests that run in a real browser, also follow the extra setup in the Nuxt testing docs.
01npm i -D @nuxt/test-utils vitest02npx vitest runWith that setup in place, the test below verifies that the home page is really rendered on the server. Complement it with end-to-end tests covering in-browser navigation, language links, loading and error states and keyboard access; type checking alone is not testing.
01import { describe, expect, it } from 'vitest'02import { fileURLToPath } from 'node:url'03import { $fetch, setup } from '@nuxt/test-utils/e2e'04 05describe('product catalog', async () => {06 await setup({07 rootDir: fileURLToPath(new URL('../..', import.meta.url)),08 server: true,09 })10 11 it('renders product content on the server', async () => {12 const html = await $fetch('/')13 expect(html).toContain('Anadolu Ürün Rehberi')14 })15})Deployment and common mistakes
There are two basic ways to deploy Nuxt. If you use hybrid rules and API endpoints, nuxt build produces a Node server and you run .output/server/index.mjs. For a fully static site that runs no server code, nuxt generate is enough; but in that case your server/api endpoints won't run. Nitro has ready-made presets for platforms such as Vercel, Netlify and Cloudflare; check the platform-specific build and runtime requirements in Nitro's deployment docs before you pick one.
01# Node sunucusu / Node server02npx nuxt build03NODE_ENV=production node .output/server/index.mjs04 05# Tamamen statik çıktı / Fully static output06npx nuxt generate01export default defineNuxtConfig({02 nitro: {03 preset: 'node-server',04 },05})Before going live, check the list below item by item. Most of these are mistakes that Nuxt projects notice only after launch, when they are more expensive to fix:
- Don't call
$fetchdirectly insetupfor data the page needs when it opens; useuseFetchoruseAsyncData. - On dynamic pages, tie the data key to the route parameter; a fixed key causes data mix-ups.
- Don't assume TypeScript types validate incoming data; validate at runtime on the server.
- Don't put secret keys under
runtimeConfig.public; that section goes to the browser. - Don't put personalized responses into SWR or any shared cache.
- Don't expect a running Nitro server from
nuxt generate; confirm dynamic addresses are prerendered. - Measure payload size and images in a production build; test the 404 page and invalid API input.
- Always run a production build with your target platform's preset before launch.
Used well, Nuxt offers both a fast first response and a single, maintainable codebase for projects from content sites to product catalogs. The real skill is not using every feature the framework offers, but choosing deliberately where and when each page is produced. As you adapt the catalog in this guide to your own project, verify each step in a production build; not everything that works on the development server behaves the same way in production.
Official sources and further reading
The version details and API behavior in this article were checked against these primary sources on 6 October 2026:
- Nuxt 4: Introduction
- Nuxt 4: Installation
- Nuxt 4: Directory Structure
- Nuxt 4: Upgrade Guide
- Nuxt Blog: Nuxt 4.5
- Nuxt: GitHub releases
- Nuxt 4: Data Fetching
- Nuxt 4: useFetch
- Nuxt 4: useAsyncData
- Nuxt 4: server/ directory
- Nuxt 4: Rendering Modes
- Nuxt 4: Prerendering
- Nuxt 4: SEO and Meta
- Nuxt i18n: SEO
- Nuxt Image: NuxtImg
- Nuxt 4: Testing
- Nuxt 4: Deployment
- Nitro: Deploy
- Nuxt Design Kit
Nuxt moves quickly. For version-specific details, rely on the documentation for the Nuxt version your project uses. The Nuxt logo is a trademark of Vercel and is used here only to identify Nuxt.