Hook: Health tech is booming, and developers are at the forefront, building personalized wellness applications. Wearables like the Oura Ring provide a treasure trove of data—sleep, readiness, and activity—that can power these experiences. But integrating this data securely and efficiently into a web application can be a complex challenge involving authentication, server-state management, and performance optimization.
What we'll build/learn: In this tutorial, we'll build a Next.js application that securely connects to the Oura Ring API using OAuth 2.0. We will then use the power of TanStack's React Query to fetch, cache, and display a user's sleep, readiness, and activity scores. We'll focus on a server-side rendering (SSR) approach to ensure our data is fresh, secure, and loaded before the page is even sent to the client.
Prerequisites:
- Node.js (v18 or later)
- A foundational understanding of React and Next.js
- An Oura Ring and an active Oura account
- Familiarity with async/await in JavaScript
Why this matters to developers: This guide tackles a real-world problem: securely handling third-party API data in a modern web framework. You'll learn a robust pattern for managing API keys and access tokens on the server, preventing exposure to the client. By mastering React Query with SSR, you'll be able to build high-performance applications that feel incredibly responsive to the user.
Understanding the Problem
Integrating a third-party API like Oura's presents several challenges:
- Authentication: The Oura API uses OAuth 2.0, a powerful but multi-step authentication protocol. We need a secure, server-side flow to handle the exchange of a temporary code for a long-lived access token.
- Security: The Client Secret and user access tokens are highly sensitive. They must never be exposed on the client-side. All API calls to Oura must originate from a secure server environment.
- Data Fetching & State Management: Fetching data introduces boilerplate. We need to manage loading states, error states, and caching to avoid redundant API calls. Doing this manually with
useStateanduseEffectcan quickly become messy. - User Experience: We want to avoid showing loading spinners whenever possible. Data should be present when the page loads, which is a perfect use case for Next.js's server-side rendering capabilities.
Our approach—using Next.js Route Handlers for the OAuth flow and API proxying, combined with React Query for state management and SSR—directly solves these challenges elegantly and efficiently.
Prerequisites
Before we start coding, let's get our Oura Application set up.
-
Create an Oura Account: If you don't have one, sign up at cloud.ouraring.com.
-
Register an OAuth Application:
- Navigate to the API Applications section.
- Click "Create New Application".
- Fill in the details. For the "Redirect URIs", enter
http://localhost:3000/api/auth/callback/oura. This is a crucial step for the OAuth flow. - Once created, you will receive a Client ID and a Client Secret. Keep these safe!
-
Set up Environment Variables:
- Create a file named
.env.localin the root of your Next.js project. - Add your Oura credentials and a secret for encrypting session data.
code# .env.local OURA_CLIENT_ID=your_client_id_here OURA_CLIENT_SECRET=your_client_secret_here NEXT_PUBLIC_OURA_CLIENT_ID=your_client_id_here APP_SECRET='complex-secret-for-session-encryption' NEXT_PUBLIC_APP_URL='http://localhost:3000'Code collapsedNote: We expose the Client ID publicly (
NEXT_PUBLIC_) because it's needed on the client to initiate the login flow. The Client Secret remains securely on the server. - Create a file named
-
Create a new Next.js App and install dependencies:
codenpx create-next-app@latest oura-react-query-app cd oura-react-query-app npm install @tanstack/react-query @tanstack/react-query-devtools iron-sessionCode collapsed@tanstack/react-query: The core library for server state management.@tanstack/react-query-devtools: A helpful tool for debugging React Query's cache.iron-session: A library for creating secure, encrypted HTTPOnly cookies to store our session data (like the Oura access token).
Step 1: Implementing the OAuth 2.0 Flow
Our first major task is to get the user authenticated with Oura. This involves three parts: redirecting the user to Oura, handling the callback, and storing the access token securely.
What we're doing
We'll create two API routes in Next.js:
/api/auth/login/oura: This route will construct the Oura authorization URL and redirect the user to it./api/auth/callback/oura: Oura will redirect the user back here after they approve our app. This route will exchange the receivedcodefor anaccess_tokenand save it in an encrypted cookie.
Implementation
First, let's set up our session management utility.
// lib/session.js
import { getIronSession } from 'iron-session';
const sessionOptions = {
password: process.env.APP_SECRET,
cookieName: 'oura-session',
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
},
};
export function getSession(req, res) {
return getIronSession(req, res, sessionOptions);
}
Now, for the login route. This route simply redirects the user to the Oura consent screen.
// app/api/auth/login/oura/route.js
import { NextResponse } from 'next/server';
export async function GET() {
const authUrl = new URL('https://cloud.ouraring.com/oauth/authorize');
authUrl.searchParams.set('client_id', process.env.NEXT_PUBLIC_OURA_CLIENT_ID);
authUrl.searchParams.set('redirect_uri', `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback/oura`);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'daily'); // Requesting daily sleep, activity, and readiness data.
return NextResponse.redirect(authUrl.toString());
}
Finally, the callback route. This is where the magic happens.
// app/api/auth/callback/oura/route.js
import { NextResponse } from 'next/server';
import { getIronSession } from 'iron-session';
export async function GET(request) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
if (!code) {
// Handle error: No code was provided
return NextResponse.redirect(new URL('/?error=oauth_error', request.url));
}
// Exchange code for access token
const tokenResponse = await fetch('https://api.ouraring.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback/oura`,
client_id: process.env.OURA_CLIENT_ID,
client_secret: process.env.OURA_CLIENT_SECRET,
}),
});
if (!tokenResponse.ok) {
// Handle error: Failed to get token
console.error('Failed to exchange code for token:', await tokenResponse.text());
return NextResponse.redirect(new URL('/?error=token_exchange_failed', request.url));
}
const tokenData = await tokenResponse.json();
// Encrypt and store the token in a cookie
const session = await getIronSession(request.cookies, new NextResponse().cookies, {
password: process.env.APP_SECRET,
cookieName: 'oura-session'
});
session.accessToken = tokenData.access_token;
await session.save();
// Redirect to the dashboard page
return NextResponse.redirect(new URL('/dashboard', request.url));
}
How it works
The OAuth 2.0 "Authorization Code" flow is a standard for secure authentication.
- Our app redirects the user to Oura's authorization server.
- The user logs into Oura and grants our application permission to access their
dailydata. - Oura redirects the user back to our specified
redirect_uri(/api/auth/callback/oura) with a temporarycode. - Our server-side callback handler receives this
code. It then makes a secure, server-to-server POST request to Oura's/oauth/tokenendpoint, including ourclient_secret. - Oura validates the request and returns an
access_token. - We use
iron-sessionto store this token in an encrypted,httpOnlycookie. This means the token is securely stored and cannot be accessed by client-side JavaScript, mitigating XSS attacks.
Step 2: Fetching Oura Data with React Query
Now that we have an access token, we can fetch the user's data. We'll create another API route to act as a proxy to the Oura API and then call it from our frontend using React Query.
What we're doing
- Create an API route
/api/oura/daily-summarythat reads the access token from the session cookie and fetches data from the official Oura API. - Set up a React Query
QueryClientProviderin our_app.jsorlayout.js. - Use the
useQueryhook on our dashboard page to call our internal API route and display the data.
Implementation
First, the server-side API proxy. This route protects our access token.
// app/api/oura/daily-summary/route.js
import { NextResponse } from 'next/server';
import { getIronSession } from 'iron-session';
import { cookies } from 'next/headers';
export async function GET() {
const session = await getIronSession(cookies(), {
password: process.env.APP_SECRET,
cookieName: 'oura-session',
});
if (!session.accessToken) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
// Get yesterday's date in YYYY-MM-DD format
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const dateStr = yesterday.toISOString().split('T');
const ouraResponse = await fetch(
`https://api.ouraring.com/v2/usercollection/daily_activity?start_date=${dateStr}&end_date=${dateStr}`,
{
headers: {
'Authorization': `Bearer ${session.accessToken}`,
},
}
);
if (!ouraResponse.ok) {
console.error('Oura API Error:', await ouraResponse.text());
return NextResponse.json({ error: 'Failed to fetch data from Oura' }, { status: ouraResponse.status });
}
const ouraData = await ouraResponse.json();
return NextResponse.json(ouraData);
}
Next, set up the React Query provider. This makes the query client available throughout our app.
// app/providers.js
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export default function Providers({ children }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
// app/layout.js
import Providers from './providers';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Finally, let's build the dashboard page that uses useQuery to fetch and display the data.
// app/dashboard/page.js
'use client';
import { useQuery } from '@tanstack/react-query';
const fetchOuraData = async () => {
const res = await fetch('/api/oura/daily-summary');
if (!res.ok) {
throw new Error('Network response was not ok');
}
return res.json();
};
export default function Dashboard() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['ouraDailySummary'],
queryFn: fetchOuraData,
});
if (isLoading) {
return <span>Loading...</span>;
}
if (isError) {
return <span>Error: {error.message}</span>;
}
// Assuming the API returns data in the expected format
const summary = data?.data?.;
if (!summary) {
return <span>No data available for yesterday.</span>
}
return (
<div>
<h1>Your Oura Daily Summary for {summary.day}</h1>
<h2>Readiness Score: {summary.score}</h2>
<ul>
<li>Activity Score: {summary.contributors?.activity_balance}</li>
<li>Sleep Score: {summary.contributors?.recovery_index}</li>
</ul>
</div>
);
}
How it works
useQuery is the magic of React Query. It takes a unique queryKey and a queryFn (an async function that returns our data). React Query automatically handles:
- Caching: If we navigate away and back to this page, the data will be served from the cache instantly while a fresh fetch happens in the background.
- Loading and Error States:
isLoadingandisErrorbooleans simplify our UI logic. - Background Refetching: It can automatically refetch data when the window is refocused, keeping our UI in sync.
Putting It All Together with Server-Side Rendering (SSR)
The client-side fetching approach works, but the user sees a "Loading..." message. We can do better. By using Next.js's getServerSideProps and React Query's prefetching mechanism, we can fetch the data on the server and render the complete HTML.
What we're doing
We will refactor our dashboard page to use a Server Component to fetch the data initially. React Query supports this through a mechanism called "hydration". We pre-fetch on the server, "dehydrate" the cache (turn it into a serializable format), and then "rehydrate" it on the client.
Implementation
// app/dashboard/page.js (Updated for SSR with App Router)
import { QueryClient, dehydrate } from '@tanstack/react-query';
import { getIronSession } from 'iron-session';
import { cookies } from 'next/headers';
import Hydrate from '@/components/Hydrate'; // A simple hydration component
import DashboardClient from './DashboardClient'; // The client component part
// This is our data fetching function, now callable on the server
const fetchOuraData = async (accessToken) => {
if (!accessToken) throw new Error('Not Authenticated');
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const dateStr = yesterday.toISOString().split('T');
const ouraResponse = await fetch(
`https://api.ouraring.com/v2/usercollection/daily_activity?start_date=${dateStr}&end_date=${dateStr}`,
{
headers: { 'Authorization': `Bearer ${accessToken}` },
}
);
if (!ouraResponse.ok) throw new Error('Failed to fetch from Oura API');
return ouraResponse.json();
};
export default async function DashboardPage() {
const queryClient = new QueryClient();
const session = await getIronSession(cookies(), {
password: process.env.APP_SECRET,
cookieName: 'oura-session',
});
// We prefetch the query on the server
await queryClient.prefetchQuery({
queryKey: ['ouraDailySummary'],
queryFn: () => fetchOuraData(session.accessToken),
});
const dehydratedState = dehydrate(queryClient);
return (
<Hydrate state={dehydratedState}>
<DashboardClient />
</Hydrate>
);
}
// components/Hydrate.js
'use client';
import { Hydrate as RQHydrate } from '@tanstack/react-query';
function Hydrate(props) {
return <RQHydrate {...props} />;
}
export default Hydrate;
// app/dashboard/DashboardClient.js (This contains our previous page logic)
'use client';
import { useQuery } from '@tanstack/react-query';
// We now need a client-side fetcher for background updates, but it won't be called on initial load.
const clientFetchOuraData = async () => {
const res = await fetch('/api/oura/daily-summary'); // uses our proxy
if (!res.ok) {
throw new Error('Network response was not ok');
}
return res.json();
};
export default function DashboardClient() {
// This hook will now find the data in the cache immediately on the client!
const { data, isLoading, isError, error } = useQuery({
queryKey: ['ouraDailySummary'],
queryFn: clientFetchOuraData,
});
// This UI logic remains the same
if (isLoading) return <span>Loading...</span>; // You won't see this on first load
if (isError) return <span>Error: {error.message}</span>;
const summary = data?.data?.;
if (!summary) return <span>No data available for yesterday.</span>;
return (
<div>
<h1>Your Oura Daily Summary for {summary.day}</h1>
<h2>Readiness Score: {summary.score}</h2>
<ul>
<li>Activity Score: {summary.contributors?.activity_balance}</li>
<li>Sleep Score: {summary.contributors?.recovery_index}</li>
</ul>
</div>
);
}
How it works
- Server-Side (
DashboardPage): This is now an async Server Component. It gets the session, creates a newQueryClient, and callsqueryClient.prefetchQuery. This fetches the data directly on the server. - Dehydration:
dehydrate(queryClient)serializes the query result and its cache metadata into JSON. - Hydration: We pass this dehydrated state to a special
<Hydrate>component. - Client-Side (
DashboardClient): When the page loads in the browser, the<QueryClientProvider>is rehydrated with this server-fetched data. TheuseQueryhook inDashboardClientsees that data for['ouraDailySummary']already exists in the cache and uses it instantly, avoiding any loading state on the initial render.
Conclusion
We have successfully built a full-featured, secure, and performant integration with the Oura Ring API in a Next.js application.
Summary of achievements:
- We implemented the complete server-side OAuth 2.0 flow to securely authenticate users.
- We used
iron-sessionto store sensitive access tokens in encrypted,httpOnlycookies. - We created a "proxy" API route to handle all communication with the Oura API, keeping our credentials safe.
- We leveraged React Query and
useQueryfor declarative data fetching and powerful state management. - Finally, we used Next.js server-side rendering with React Query's hydration to pre-fetch data, eliminating initial load times and improving SEO.
This powerful pattern is not limited to Oura; you can apply it to any third-party API integration, building robust and delightful user experiences.
Next steps for readers:
- Fetch more data: Explore other Oura API endpoints like sleep periods or workouts.
- Add a "Log Out" button: Create an API route that destroys the session.
- Handle token refresh: The Oura access token will eventually expire. Implement logic to use the
refresh_token(obtained in the initial token exchange) to get a new access token automatically. - Visualize the data: Use a charting library like Chart.js or D3 to create a beautiful health dashboard.
Resources
- Official Oura API V2 Documentation: Oura Cloud API Docs
- TanStack React Query Docs: React Query SSR
- Next.js Documentation: Route Handlers
- Iron Session on GitHub: iron-session