1. Introduction
Nuxt 4 is an incredibly powerful framework, especially with its SSR-first architecture powered by Nitro 4. However, many developers run into a frustrating issue:
Skeleton loaders remain visible forever, and real data never appears—especially when JavaScript is disabled.
This article is a complete deep-dive into:
- How Nuxt 4 handles SSR
- Why some pages fail when JavaScript is disabled
- Why skeletons get stuck
- How to design fully SSR-compatible pages
- Debugging strategies
- Correct use of
useFetch() - Architecture best practices
- Avoiding hydration pitfalls
This guide is based on real production problems and real solutions.
2. Understanding SSR in Nuxt 4
Nuxt 4 uses Nitro 4 for rendering.
SSR means:
- The server renders the page.
-
useFetch()runs on the server. - HTML is generated with data already included.
- Browser receives a fully rendered page.
- If JS is enabled, hydration happens.
If JavaScript is disabled, the page should still display complete content — if SSR is implemented correctly.
3. The Root Cause of Skeleton Loaders Never Disappearing
If your data fetching happens only on the client, you will see:
- Skeleton forever
- No real content
- Empty state
- Hydration errors
Most commonly caused by:
onMounted(async () => {
data.value = await $fetch('/api/data')
})
onMounted() never runs on the server, so SSR produces no content.
4. Client-Side Fetching vs Server-Side Fetching
❌ Client-side only:
onMounted(async () => {
data.value = await $fetch('/api/data')
})
✔ Server-side rendering with useFetch():
const { data, pending } = await useFetch('/api/data', {
server: true
})
Server fetching ensures:
- Data loads before HTML is delivered
- Skeleton disappears automatically
- Page works without JavaScript
5. How Skeleton Loaders Work Internally
Nuxt skeleton loaders depend on pending.
<template>
<Skeleton v-if="pending" />
<Content v-else />
</template>
If pending stays true, skeleton never leaves.
Reasons:
- useFetch executes on the client
- Hydration mismatch
- Data not fetched before SSR
- Wrong composable structure
6. Fixing Skeleton Issues With SSR-First Fetching
Correct SSR-fetching:
await useFetch('/api/data', {
server: true,
lazy: false
})
Why these options matter:
-
server: true: forces SSR execution -
lazy: false: ensures fetch runs immediately
7. The Architecture Mistake: Fetching Data Inside Components
Avoid this:
<!-- inside a component -->
<script setup>
const { data } = useFetch('/api/data')
</script>
This breaks SSR because:
- Each component re-fetches
- Execution happens after hydration
- SSR doesn’t know about the data
Correct pattern:
- Fetch at the page level
- Pass data as props
8. Building SSR-Compatible Composables
Bad composable:
export const useItems = () => useFetch('/api/items')
Correct composable:
export const useItems = () => {
return useFetch('/api/items', {
server: true,
lazy: false
})
}
9. Full SSR-Ready Page Example (Works Without JavaScript)
<script setup>
const { data: items, pending } = await useFetch('/api/items', {
server: true,
lazy: false
})
</script>
<template>
<ItemSkeleton v-if="pending" />
<div v-else>
<ItemCard
v-for="item in items"
:key="item.id"
:item="item"
/>
</div>
</template>
This page:
- Renders instantly
- Works without JavaScript
- Avoids hydration mismatch
- Displays content on first paint
10. Understanding Hydration and Why It Breaks UI State
Hydration attaches Vue to SSR-rendered HTML.
Hydration fails when:
- SSR and client outputs don’t match
- State differs between server and client
- Data shape changes
- Skeleton conditions differ
When hydration breaks → UI becomes stuck in loading state.
11. Debugging SSR Problems Like a Pro
Technique 1: Disable JavaScript
If the page breaks → your SSR is not fully implemented.
Technique 2: Verify server execution
console.log("Is server?", process.server)
Technique 3: Check Nuxt DevTools
View SSR state flow.
Technique 4: View page source
If HTML doesn’t contain data → SSR didn’t run fetch.
12. Why useFetch() Sometimes Runs on the Client Only
Common reasons:
- Key function depends on client-side values
- Conditional rendering prevents SSR execution
- Missing options:
server: true - Using watchers to trigger fetch
Wrong pattern:
const id = ref(null)
useFetch(() => `/api/item/${id.value}`)
SSR does not know id.
13. Nitro’s Role in SSR Data Handling
Nitro handles:
- Server file execution
- API routing
- Caching
- Lazy loading
- Edge deployments
Understanding Nitro helps prevent:
- Unnecessary client-fetches
- Wrong caching behavior
- Inconsistent SSR render
14. API Response Design for SSR Stability
Good SSR-friendly API example:
export default defineEventHandler(() => {
return {
success: true,
data: [
{ id: 1, title: "Example A" },
{ id: 2, title: "Example B" }
]
}
})
Guidelines:
- Consistent keys
- Predictable object structures
- Avoid deeply nested unpredictability
15. Avoid Suspense Pitfalls
Suspense can break SSR.
❌ Wrong:
<Suspense>
<ChildComponent />
</Suspense>
If child fetches → fallback shows forever when JS is disabled.
✔ Correct:
<Suspense>
<ServerRenderedBlock />
<template #fallback>
<Skeleton />
</template>
</Suspense>
16. Managing Large Component Trees in SSR
Rules:
- Fetch data only once (at page level)
- Pass ready data downward
- Avoid client-side lifecycle hooks for initial load
- Make components stateless regarding initial data
17. Performance Benefits of SSR-First Design
SSR-first yields:
- Faster LCP
- Better SEO
- Fully visible first paint
- No empty content
- Smooth hydration
- Better performance on slow devices
- Works without JavaScript entirely
18. SSR Testing Checklist
✔ Disable JavaScript
✔ Check HTML source for real data
✔ Ensure skeleton disappears
✔ Ensure no hydration warnings
✔ Confirm fetch runs on server
✔ Validate composables return SSR data
19. Best Practices Summary
- Prefer SSR fetching always
- Avoid data fetching inside components
- Avoid onMounted for initial data
- Use composables sensibly
- Ensure skeleton logic depends on SSR pending
- Test pages with JS disabled
- Ensure API responses are predictable
- Avoid hydration mismatches
20. Final Exam (Great for Dev.to Readers)
Q1: Why do skeleton loaders remain visible when JavaScript is disabled?
Q2: Convert this into SSR-compatible code:
onMounted(async () => {
list.value = await $fetch('/api/list')
})
Q3: Write an SSR-first composable using useFetch.
Q4: Describe what causes hydration mismatch errors.
Q5: Build a skeleton fallback pattern that works without JavaScript.
Conclusion
SSR in Nuxt 4 is powerful — but only when you understand how to use it correctly.
By adopting an SSR-first architecture, eliminating client-only data fetching, designing stable composables, and structuring your UI for hydration safety, your application becomes:
- Faster
- More stable
- SEO-optimized
- Accessible
- Fully functional with JavaScript disabled
- Production-ready
This guide provides the deep technical understanding needed to achieve that level of reliability in Nuxt 4.

Top comments (0)