JSON in Vue 3: Composition API, Fetch, Pinia, and TypeScript

Last updated:

Vue 3's Composition API makes working with JSON APIs straightforward: fetch data in onMounted, store it in ref or reactive, and render it with v-for. This guide covers every layer — raw fetch(), VueUse composables, reactive() forms, Pinia stores, TypeScript typing, and Nuxt server-side fetching — with copy-paste Vue SFC examples throughout.

Fetching JSON with ref and setup()

The foundational pattern: declare ref state for data, loading, and error; run fetch() inside onMounted; assign products.value after parsing.

<script setup lang="ts">
import { ref, onMounted } from 'vue'

interface Product {
  id: number
  name: string
  price: number
}

const products = ref<Product[]>([])
const loading = ref(true)
const error = ref<string | null>(null)

onMounted(async () => {
  try {
    const res = await fetch('/api/products')
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    products.value = await res.json() as Product[]
  } catch (e) {
    error.value = (e as Error).message
  } finally {
    loading.value = false
  }
})
</script>

<template>
  <div v-if="loading">Loading...</div>
  <div v-else-if="error">Error: {{ error }}</div>
  <ul v-else>
    <li v-for="product in products" :key="product.id">
      {{ product.name }} — ${{ product.price }}
    </li>
  </ul>
</template>

Composable useFetch (VueUse)

VueUse's useFetch replaces the boilerplate above with a single line. Chain .json() for automatic response parsing. For reactive URLs (pagination, search), pass a Ref<string> and it auto-refetches on change.

<script setup lang="ts">
// Option A: VueUse useFetch — recommended
import { useFetch } from '@vueuse/core'

const { data: products, isFetching, error } = useFetch<Product[]>('/api/products').json()
// data is Ref<Product[] | null>, isFetching is Ref<boolean>

// Option B: custom composable
import { ref, watch, type Ref } from 'vue'

function useApiData<T>(url: Ref<string> | string) {
  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  async function load() {
    loading.value = true
    error.value = null
    try {
      const res = await fetch(typeof url === 'string' ? url : url.value)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      data.value = await res.json()
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  if (typeof url !== 'string') {
    watch(url, load, { immediate: true })
  } else {
    load()
  }

  return { data, loading, error, refetch: load }
}
</script>

POST JSON with reactive Forms

Use reactive() for form state so that v-model binds directly without .value. Serialize the entire form object with JSON.stringify(form) in the request body.

<script setup lang="ts">
import { reactive, ref } from 'vue'

const form = reactive({
  name: '',
  email: '',
  role: 'user',
})

const submitting = ref(false)
const success = ref(false)

async function submit() {
  submitting.value = true
  try {
    const res = await fetch('/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form),
    })
    if (!res.ok) throw new Error(await res.text())
    success.value = true
  } finally {
    submitting.value = false
  }
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model="form.name" placeholder="Name" required />
    <input v-model="form.email" type="email" required />
    <button :disabled="submitting">
      {{ submitting ? 'Saving...' : 'Create User' }}
    </button>
    <p v-if="success">User created!</p>
  </form>
</template>

Displaying JSON Data

Vue's template directives map cleanly onto JSON structures: v-for="item in array" for arrays, v-for="(value, key) in obj" for object key iteration, and a <pre> block for debug display.

<template>
  <!-- Render JSON object as key-value list -->
  <dl v-if="user">
    <template v-for="(value, key) in user" :key="key">
      <dt>{{ key }}</dt>
      <dd>{{ JSON.stringify(value) }}</dd>
    </template>
  </dl>

  <!-- Debug display -->
  <pre>{{ JSON.stringify(user, null, 2) }}</pre>

  <!-- Render nested array of objects as table -->
  <table v-if="products.length">
    <thead>
      <tr>
        <th>ID</th><th>Name</th><th>Price</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="p in products" :key="p.id">
        <td>{{ p.id }}</td>
        <td>{{ p.name }}</td>
        <td>{{ p.price.toFixed(2) }}</td>
      </tr>
    </tbody>
  </table>
</template>

Pinia Store with JSON State

Pinia is Vue's official state management library. Use the setup store syntax with ref() for state and plain async functions for actions. State must be JSON-serializable for DevTools time-travel and pinia-plugin-persistedstate to work.

// stores/products.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface Product { id: number; name: string; price: number; category: string }

export const useProductStore = defineStore('products', () => {
  const products = ref<Product[]>([])
  const loading = ref(false)

  const byCategory = computed(() =>
    products.value.reduce<Record<string, Product[]>>((acc, p) => {
      ;(acc[p.category] ??= []).push(p)
      return acc
    }, {})
  )

  async function fetchAll() {
    loading.value = true
    try {
      const res = await fetch('/api/products')
      products.value = await res.json()
    } finally {
      loading.value = false
    }
  }

  function addProduct(product: Omit<Product, 'id'>) {
    // Optimistic update
    products.value.push({ ...product, id: Date.now() })
  }

  return { products, loading, byCategory, fetchAll, addProduct }
})

TypeScript Interfaces for JSON APIs

Define interfaces that mirror the API JSON shape exactly. Use a generic wrapper for paginated or enveloped responses. Add a type guard function for runtime error discrimination.

// types/api.ts
export interface ApiResponse<T> {
  data: T
  meta?: {
    total: number
    page: number
    per_page: number
  }
  error?: string
}

export interface User {
  id: number
  name: string
  email: string
  created_at: string   // ISO 8601 — parse with new Date() when needed
  role: 'admin' | 'editor' | 'viewer'
}

// Composable with typed response
const { data } = useFetch<ApiResponse<User[]>>('/api/users').json()
// data.value?.data → User[]
// data.value?.meta?.total → number

// Type guard for API errors
function isApiError(body: unknown): body is { error: string; code: number } {
  return typeof body === 'object' && body !== null && 'error' in body
}

Nuxt and Server-Side JSON Fetching

Nuxt's useFetch and useAsyncData run on both server and client with SSR deduplication — data fetched during SSR is hydrated to the client without a second network request. $fetch (from ofetch) auto-serializes request bodies to JSON.

<!-- Nuxt 3 — server-side data fetching with $fetch (ofetch) -->
<script setup lang="ts">
// useFetch — server + client, SSR-safe, cached
const { data: products } = await useFetch<Product[]>('/api/products', {
  key: 'products',      // unique cache key
  transform: (items) => items.sort((a, b) => a.name.localeCompare(b.name)),
})

// useAsyncData — for $fetch calls with more control
const { data: user } = await useAsyncData('user', () =>
  $fetch<User>(`/api/users/${route.params.id}`)
)

// $fetch alone (no SSR dedup — use sparingly in setup())
const created = await $fetch('/api/users', {
  method: 'POST',
  body: { name: 'Alice', email: 'alice@example.com' },  // auto JSON.stringify
})
</script>

Vue JSON Fetching Comparison

MethodLoading stateSSR safeAuto abort
fetch() in onMountedManual refYes (client only)No
VueUse useFetchisFetching refYesYes
Nuxt useFetchpending refYes + SSR dedupYes
Nuxt $fetchManualYesNo
Pinia actionStore refWith Nuxt pluginNo

Definitions

ref()
Vue 3 reactivity primitive that wraps a value; access/set with .value; works for primitives and objects; Vue auto-unwraps ref in templates (no .value needed).
reactive()
Vue 3 reactivity primitive for objects; returns a Proxy; properties are directly reactive without .value; cannot be replaced wholesale — destructuring loses reactivity.
Pinia
Vue's official state management library; stores are defined with defineStore(); state is reactive JSON-serializable objects; DevTools support time-travel debugging.
useFetch (VueUse)
Composable from the @vueuse/core library that wraps fetch() with reactive loading, error, and data state; supports TypeScript generics and URL reactivity.
$fetch (Nuxt/ofetch)
Nuxt's fetch primitive based on the ofetch library; auto-serializes request body to JSON; handles response parsing; works identically in server and client contexts.

FAQ

How do I fetch JSON in Vue 3 Composition API?

Use fetch() inside onMounted() with ref state for data, a loading flag, and error. Declare const products = ref<Product[]>([]) and run the async fetch in onMounted, assigning products.value = await res.json(). Avoid putting fetch calls directly at the top level of script setup — use onMounted to ensure the component is mounted and to avoid SSR issues. The VueUse useFetch composable is the recommended shorthand: const { data, isFetching, error } = useFetch<Product[]>('/api/products').json() — loading state, error handling, and refetch are all built in.

What is the difference between ref and reactive for JSON data in Vue?

ref() wraps primitives and objects in a single .value accessor. reactive() makes an entire object reactive without .value. For JSON arrays from APIs, ref<Item[]>([]) is recommended because you can reassign products.value = newArray wholesale — reactive() arrays cannot be replaced entirely. For form state, reactive({ name: "", email: "" }) is ergonomic since v-model binds directly without .value. Key rule: destructuring a reactive() object loses reactivity; use toRefs() if you need to destructure.

How do I display JSON data in a Vue template?

Use v-for="item in array" for JSON arrays; use v-for="(value, key) in obj" to iterate JSON object keys. For debug display, use <pre>{{ JSON.stringify(data, null, 2) }}</pre>. Always bind :key on v-for items using a stable unique field (like item.id) for efficient DOM patching. Avoid calling JSON.stringify in template expressions for production rendering — use computed properties to transform data before displaying it in the template.

How does Pinia store JSON API data?

Use defineStore() with the setup syntax: declare state as ref() values inside the store function, and write actions as plain async functions that call fetch() and assign to state refs. Pinia state is reactive and JSON-serializable, so Vue DevTools can inspect and time-travel debug every state change. Use computed() inside the store for derived data — filtered or grouped arrays. The pinia-plugin-persistedstate plugin auto-syncs store state to localStorage using JSON.stringify/JSON.parse.

How do I POST JSON with Vue 3 fetch?

Use a reactive() form object, bind inputs with v-model, and call fetch() with method: 'POST', headers: { "Content-Type": "application/json" }, and body: JSON.stringify(form). Always use @submit.prevent on the form element. Track submitting state with a ref<boolean> and bind :disabled="submitting" on the submit button to prevent double-submission. VueUse useFetch also supports POST: const { execute } = useFetch(url, { method: "POST", body: form }).json().

How do I type JSON API responses in Vue with TypeScript?

Define a TypeScript interface that mirrors the API JSON shape, then use it as a generic: const user = ref<User | null>(null). Pass the generic to useFetch: useFetch<User[]>('/api/users').json() — the data ref is typed as Ref<User[] | null>. For paginated responses, define a wrapper interface: ApiResponse<T> with data: T and meta. For runtime safety when you cannot fully trust the API shape, use Zod: call UserSchema.parse(await res.json()) before assigning to the ref.

What is VueUse useFetch and how does it handle JSON?

VueUse useFetch is a composable from @vueuse/core that wraps the Fetch API with reactive state, returning { data, isFetching, error, execute, abort } as refs. Chain .json() to automatically parse the response. Pass a TypeScript generic — useFetch<Product[]>(url).json() — for a typed data ref. If url is a Ref<string>, useFetch watches it and automatically refetches when it changes — ideal for paginated or search-driven queries. It creates an AbortController internally and cleans up on component unmount.

How does Nuxt handle JSON fetching differently from Vue?

Nuxt's useFetch and useAsyncData run on both server and client with SSR deduplication: data fetched on the server is hydrated to the client without a second network call, so the HTML already contains the data on first load. $fetch (from ofetch) auto-serializes the request body to JSON without JSON.stringify, parses responses, and works identically in server and client contexts. For user interaction-triggered fetches (button clicks), use $fetch directly or useFetch with { server: false }.

Further reading and primary sources

  • Vue 3 Composition API: ComposablesOfficial Vue guide to writing reusable composables with ref, reactive, and lifecycle hooks
  • VueUse useFetchFull API reference for VueUse useFetch including options, interceptors, and abort support
  • Pinia docsOfficial Pinia documentation covering defineStore, actions, getters, and plugins
  • JSON in React (Jsonic)Fetch, useState, useEffect, and SWR patterns for JSON in React — compare with Vue approach
  • TypeScript JSON Types (Jsonic)Safe JSON.parse with TypeScript: unknown type, Zod validation, and type guards