Loading content...
Idea
Let's compare the above libraries for writing GraphQL queries in Nuxt.—Consider this post a memo to self about recent GraphQL routes I've explored. By no means is what I say here exhaustive.
Libraries I will not discuss are: NuxtApollo and TanStack Query. They are both solid options. You could even say they are mainstays for large builds.
I have used Apollo in the past but not TanStack so lets not cover those for now as I would only have 50% idea what I'm talking about.
With those precautions out of the way, let's take a look at the libraries I will be comparing.
NuxtGraphQL Client
At first, the queries for this folio were made with NuxtGraphQL Client (NGC). It's a module that works well for small projects. NGC offers automatic code generation which enables you to generate TypeScript types from your GraphQL schema. No more dark magic with types.
Additionally, NGC offers basic deduplication and caching.
(For a brief overview of how to use this client, I recommend reading the official documentation).
However, if you do not want your API key to reside in your nuxt.config.ts on GitHub, available to alien eyes (on the dark side of the moon), you will have to do some digging to keep things secure.
NGC Security Consideration
Out of the box, NGC requires a public API key in your nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
public: {
graphqlClient: {
clients: {
default: {
host: "your-graphql-endpoint.com",
token: "your-api-key-here", // This will be exposed to the client
},
},
},
},
},
})
The key point is that any values in the public runtime config are exposed to the client-side. By moving the API call to a server route, we can use private runtime config values (those not nested under public), which are only available server-side. This means your API token stays secure and never reaches the client's browser.
NuxtGraphQL Client More Secure: Server Route
To the task at hand. Let's create a server route for NGC:
- Create a secure server-side GraphQL proxy (
/server/api/graphql.ts):
import { H3Error } from "h3"
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
try {
const body = await readBody(event)
// Basic validation
if (!body || typeof body !== "object") {
throw createError({
statusCode: 400,
statusMessage: "Invalid request body",
})
}
const response = await $fetch("https://your-graphql-endpoint.com/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.graphqlSecret}`,
},
body: JSON.stringify(body),
// Add timeout and other fetch options as needed
})
return response
} catch (error) {
console.error("GraphQL Proxy Error:", error)
// Handle different types of errors
if (error instanceof H3Error) {
throw error // Re-throw H3 errors
}
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || "Internal Server Error",
})
}
})
Safer days. With the server route sorted out, your security aware Nuxt config will then change into something like this:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// Server-side only
graphqlSecret: process.env.GRAPHQL_SECRET,
},
graphqlClient: {
clients: {
default: {
host: "/api/graphql", // Points to your server route
// No token needed here as it's handled server-side
},
},
},
})
Now your proxy is standing in for the token, not exposed on the client side. However, if you're going for this approach, I would argue that you might as well adapt the approach of this folio: use Pinia and reap the benefits thereof.
So, without further ado, let's take a look at Pinia. Then we will take a brief look at Pinia Colada, not used in this folio, but a versatile approach for larger projects.
Pinia Only
The current folio implementation uses a simple Pinia store to manage GraphQL data. Here are some reasons for this.
Why Choose Pinia Over NuxtGraphQL Client:
- Minimal Bundle Size: Low overhead since Pinia leverages the native Fetch API, keeping your application lean and fast.
- Simplified Debugging: With no GraphQL client magic, debugging is straightforward - you can easily trace every step of the data flow.
- No Client Lock-in: Your implementation remains framework-agnostic, making it easier to migrate or adapt as your needs change.
- Faster Initial Setup: Skip the configuration overhead of a GraphQL client and get straight to writing queries.
- Predictable State Management: Pinia's store pattern provides a clear, centralized location for all your data and business logic.
- Better Performance for Simple Use Cases: For basic GraphQL operations, the lack of client overhead can actually result in better performance.
- Easier Testing: Test your data fetching and state management separately, with fewer moving parts to mock.
That being said, does not mean that NuxtGraphQL Client is not a solid choice. It is a great module that really helped me get going with GraphQL in Nuxt.
But alas, I digress. Back to it. Let us take a look at my current and very simple Pinia implementation—by no means perfect. But hey, it works!
Current Pinia Implementation:
// store/useBlogStore.ts
export const useBlogStore = defineStore("blog", {
state: () => ({
posts: [] as Post[],
loading: false,
error: null as string | null,
}),
actions: {
async fetchPosts() {
this.loading = true
this.error = null
try {
const query = `
query GetPosts {
posts {
id
title
excerpt
publishedAt
}
}
`
const { data } = await $fetch("/api/graphql", {
method: "POST",
body: { query },
})
this.posts = data.posts
} catch (error) {
this.error = error.message || "Failed to fetch posts"
} finally {
this.loading = false
}
},
},
})
Notice how we can leverage Pinia's built-in state management with actions and getters without any additional setup. No type generation, client configuration, or complex store setup is needed—just a straightforward Pinia store that makes API calls.
In all three examples we are indeed calling server/api; we are not calling our endpoint directly (whatever headless CMS you use) from the Pinia store. So the server/api code I posted above for NGC, doesn't really change. That is, we are using a proxy.
Ok, enough of basic Pinia for this session. Now let's cast our net towards Pinia Colada 🍍.
GraphQL Approach With Pinia Colada
Let's get straight into the query to the server route on this one because I need to go and drink some coffee.
// store/useBlogStore.ts
import { useQuery } from 'pinia-colada'
export const useBlogStore = defineStore("blog", () => {
const { data, error, isLoading } = useQuery(
"blog-posts", // Cache key
async () => {
try {
const response = await $fetch("/api/graphql", {
method: "POST",
body: {
query: `
query GetPosts {
posts {
id
title
excerpt
publishedAt
}
}
`
}
})
return response.data.posts
} catch (err) {
console.error("Error fetching posts:", err)
throw err
}
},
{
staleTime: 1000 * 60 * 5, // 5 minutes cache
refetchOnWindowFocus: true,
}
)
if (error.value) {
console.error('Failed to load posts:', error.value)
}
return {
posts: data,
error,
isLoading,
}
})
The useQuery hook above handles the request lifecycle, and the data is automatically reactive in your components. And there are many plus sides. Here are some 🐧
Key Features that Pinia Colada offers:
- Automatic Request Deduplication
- Multiple components requesting the same data? Only one network request is made
- All components receive the same cached response
- Cache Key Based
// Both components use the same cache key 'blog-posts' const { data } = useQuery("blog-posts", fetchBlogPosts) const { data: sameData } = useQuery("blog-posts", fetchBlogPosts) // Uses cache - Stale-While-Revalidate
- Returns cached data immediately if available (even if stale)
- Fetches fresh data in the background
- Updates all components when new data arrives
- Dependent Queries
// Second query waits for userId const { data: user } = useQuery("current-user", fetchUser) const { data: posts } = useQuery( ["user-posts", user.value?.id], () => fetchUserPosts(user.value.id), { enabled: !!user.value?.id } )
Finally to round up what we have been talking about in this little writeup, here is an overview.
Comparison of Approaches:
| Feature | Pinia | NGC Client | Pinia Colada |
|---|---|---|---|
| Type Generation | Manual | Automatic | Manual |
| Bundle Size | Small | Larger | Medium |
| Learning Curve | Lower | Higher | Medium |
| Built-in Caching | No | Yes | Yes |
| Automatic Deduplication | No | Yes | Yes |
| Error Handling | Manual | Built-in | Built-in |
Conclusion
- For simple projects: The basic Pinia approach is lightweight and straightforward.
- For GraphQL-heavy applications: NuxtGraphQL Client provides excellent tooling.
- For complex state management: Pinia Colada offers a great balance of features and simplicity.