Next.js and Strapi Client SDK Example
How to get started with Strpai and Next.js 16 using Strapi Client SDK.
How to get started with Strpai and Next.js 16 using Strapi Client SDK.
This comprehensive guide explains how we integrate the @strapi/client SDK in our Next.js application, covering architecture, authentication, error handling, and usage patterns from beginner to advanced implementations.
flowchart TB
subgraph "Next.js App"
Page[Page Component]
DataLayer[Data Layer]
SDK[Strapi SDK Wrapper]
ErrorBoundary[Global Error Boundary]
NotFound[Not Found Page]
end
subgraph "Strapi Backend"
API[Strapi API]
end
Page --> DataLayer
DataLayer --> SDK
SDK --> API
SDK -.->|StrapiError| ErrorBoundary
Page -.->|notFound| NotFound
style SDK fill:#f9f,stroke:#333
style ErrorBoundary fill:#ff9,stroke:#333
style NotFound fill:#6bcb77,stroke:#333
src/
├── lib/
│ ├── strapi-sdk.ts # Advanced SDK wrapper with error handling
│ └── strapi-sdk-simple.ts # Simple SDK for beginners
├── data/
│ ├── index.ts # API exports
│ └── landing-page.ts # Page-specific data fetchers
└── app/
├── page.tsx # Page component
├── global-error.tsx # Error boundary
└── not-found.tsx # 404 page
For beginners, start with a simple SDK wrapper without complex error handling. Errors will propagate naturally to the calling code.
strapi-sdk-simple.ts)import { strapi } from '@strapi/client';
// Create the Strapi client
const client = strapi({
baseURL: "http://localhost:1337/api",
});
/**
* Fetch a single type (e.g., homepage, settings)
*/
export async function getSingleType(name: string) {
const { data } = await client.single(name).find();
return data;
}
/**
* Fetch all items from a collection (e.g., articles, products)
*/
export async function getCollection(name: string) {
const { data } = await client.collection(name).find();
return data;
}
/**
* Fetch a single item from a collection by ID
*/
export async function getDocument(collection: string, id: string) {
const { data } = await client.collection(collection).findOne(id);
return data;
}
/**
* Fetch a single item from a collection by slug
*/
export async function getDocumentBySlug(collection: string, slug: string) {
const { data } = await client.collection(collection).find({
filters: { slug: { $eq: slug } },
});
return data[0] ?? null;
}
// In your page or component
async function loadArticle(slug: string) {
try {
const article = await getDocumentBySlug("articles", slug);
if (!article) {
// Handle not found
return null;
}
return article;
} catch (error) {
console.error("Failed to load article:", error);
// Handle error (show message, redirect, etc.)
return null;
}
}
sequenceDiagram
participant Page
participant SDK
participant Strapi
Page->>SDK: getCollection("articles")
SDK->>Strapi: HTTP GET /api/articles
alt Success
Strapi-->>SDK: 200 OK + Data
SDK-->>Page: Data
else Error
Strapi-->>SDK: Error Response
SDK-->>Page: Throws Error
Page->>Page: catch(error)
end
For production applications, use a custom error class and centralized error handling.
class StrapiError extends Error {
constructor(
message: string,
public readonly contentType: string,
public readonly cause?: unknown
) {
super(message);
this.name = 'StrapiError';
}
}
This custom error provides:
const createClient = (config?: Omit<Config, 'baseURL'>) => {
const clientConfig: Config = {
baseURL: getStrapiURL() + "/api",
...config,
};
// Only add auth if token exists and not overridden by config
const token = process.env.STRAPI_API_TOKEN;
if (token && !config?.auth) {
clientConfig.auth = token;
}
return strapi(clientConfig);
};
strapi-sdk.ts)import { strapi } from '@strapi/client';
import type { API, Config } from '@strapi/client';
import { draftMode } from 'next/headers';
import { getStrapiURL } from "@/lib/utils";
class StrapiError extends Error {
constructor(
message: string,
public readonly contentType: string,
public readonly cause?: unknown
) {
super(message);
this.name = 'StrapiError';
}
}
const createClient = (config?: Omit<Config, 'baseURL'>) => {
const clientConfig: Config = {
baseURL: getStrapiURL() + "/api",
...config,
};
const token = process.env.STRAPI_API_TOKEN;
if (token && !config?.auth) {
clientConfig.auth = token;
}
return strapi(clientConfig);
};
/**
* Fetches a collection type from Strapi.
* @throws {StrapiError} When the fetch fails
*/
export async function fetchCollectionType<T = API.Document[]>(
collectionName: string,
options?: API.BaseQueryParams,
config?: Omit<Config, 'baseURL'>
): Promise<T> {
const { isEnabled: isDraftMode } = await draftMode();
try {
const { data } = await createClient(config)
.collection(collectionName)
.find({
...options,
status: isDraftMode ? 'draft' : 'published',
});
return data as T;
} catch (error) {
throw new StrapiError(
`Failed to fetch collection "${collectionName}"`,
collectionName,
error
);
}
}
/**
* Fetches a single type from Strapi.
* @throws {StrapiError} When the fetch fails
*/
export async function fetchSingleType<T = API.Document>(
singleTypeName: string,
options?: API.BaseQueryParams,
config?: Omit<Config, 'baseURL'>
): Promise<T> {
const { isEnabled: isDraftMode } = await draftMode();
try {
const { data } = await createClient(config)
.single(singleTypeName)
.find({
...options,
status: isDraftMode ? 'draft' : 'published',
});
return data as T;
} catch (error) {
throw new StrapiError(
`Failed to fetch single type "${singleTypeName}"`,
singleTypeName,
error
);
}
}
/**
* Fetches a single document from a collection by documentId.
* @throws {StrapiError} When the fetch fails
*/
export async function fetchDocument<T = API.Document>(
collectionName: string,
documentId: string,
options?: API.BaseQueryParams,
config?: Omit<Config, 'baseURL'>
): Promise<T> {
const { isEnabled: isDraftMode } = await draftMode();
try {
const { data } = await createClient(config)
.collection(collectionName)
.findOne(documentId, {
...options,
status: isDraftMode ? 'draft' : 'published',
});
return data as T;
} catch (error) {
throw new StrapiError(
`Failed to fetch document "${documentId}" from "${collectionName}"`,
collectionName,
error
);
}
}
/**
* Fetches a single document from a collection by slug.
* @throws {StrapiError} When the fetch fails or document not found
*/
export async function fetchCollectionTypeBySlug<T = API.Document>(
collectionName: string,
slug: string,
options?: API.BaseQueryParams,
config?: Omit<Config, 'baseURL'>
): Promise<T | null> {
const { isEnabled: isDraftMode } = await draftMode();
try {
const { data } = await createClient(config)
.collection(collectionName)
.find({
...options,
filters: {
slug: { $eq: slug },
...options?.filters,
},
status: isDraftMode ? 'draft' : 'published',
});
return (data[0] as T) ?? null;
} catch (error) {
throw new StrapiError(
`Failed to fetch "${collectionName}" with slug "${slug}"`,
collectionName,
error
);
}
}
/**
* Creates an authenticated client for user-specific requests.
*/
export function createAuthenticatedClient(jwt: string) {
return createClient({ auth: jwt });
}
export { StrapiError };
fetchSingleType<T>Fetches a single type from Strapi (e.g., homepage, settings).
export async function fetchSingleType<T = API.Document>(
singleTypeName: string,
options?: API.BaseQueryParams,
config?: Omit<Config, 'baseURL'>
): Promise<T>
Example:
const landingPage = await fetchSingleType<TLandingPage>("landing-page");
fetchCollectionType<T>Fetches a collection of documents (e.g., articles, products).
export async function fetchCollectionType<T = API.Document[]>(
collectionName: string,
options?: API.BaseQueryParams,
config?: Omit<Config, 'baseURL'>
): Promise<T>
Example:
const articles = await fetchCollectionType<TArticle[]>("articles", {
populate: "*",
filters: { published: true },
});
fetchDocument<T>Fetches a single document by ID from a collection.
export async function fetchDocument<T = API.Document>(
collectionName: string,
documentId: string,
options?: API.BaseQueryParams,
config?: Omit<Config, 'baseURL'>
): Promise<T>
Example:
const article = await fetchDocument<TArticle>("articles", "abc123");
fetchCollectionTypeBySlug<T>Fetches a single document by slug from a collection.
export async function fetchCollectionTypeBySlug<T = API.Document>(
collectionName: string,
slug: string,
options?: API.BaseQueryParams,
config?: Omit<Config, 'baseURL'>
): Promise<T | null>
Example:
const article = await fetchCollectionTypeBySlug<TArticle>("articles", "my-article-slug");
createAuthenticatedClientCreates a client with user JWT authentication.
export function createAuthenticatedClient(jwt: string) {
return createClient({ auth: jwt });
}
Example:
const client = createAuthenticatedClient(userJwt);
const userProfile = await client.collection("profiles").findOne(userId);
flowchart LR
subgraph "Authentication Options"
A[No Token] -->|Public API| B[Unauthenticated Request]
C[STRAPI_API_TOKEN] -->|Server API Token| D[Token Auth Request]
E[User JWT] -->|User Auth| F[JWT Auth Request]
end
| Mode | Use Case | Configuration |
|---|---|---|
| Public API | No STRAPI_API_TOKEN set | Automatic |
| API Token | Server-to-server requests | Set STRAPI_API_TOKEN env var |
| User JWT | User-specific requests | Pass { auth: jwt } in config |
All fetch functions automatically check Next.js draft mode and adjust the request:
flowchart TD
A[Fetch Request] --> B{Draft Mode?}
B -->|Yes| C[status: 'draft']
B -->|No| D[status: 'published']
C --> E[Return Data]
D --> E
const { isEnabled: isDraftMode } = await draftMode();
const { data } = await createClient(config)
.single(singleTypeName)
.find({
...options,
status: isDraftMode ? 'draft' : 'published',
});
flowchart TD
A[API Request] --> B{Success?}
B -->|Yes| C[Return Data]
B -->|No| D[Error Handling]
D --> E{Error Type?}
E -->|Network| F[Connection Error]
E -->|404| G[Not Found]
E -->|401/403| H[Auth Error]
E -->|500| I[Server Error]
F --> J[User Feedback]
G --> J
H --> J
I --> J
sequenceDiagram
participant User
participant Page as Page Component
participant DataLayer as Data Layer
participant SDK as Strapi SDK
participant Strapi as Strapi API
participant ErrorBoundary as Error Boundary
User->>Page: Visit /articles/my-post
Page->>DataLayer: getArticleBySlug("my-post")
DataLayer->>SDK: fetchCollectionTypeBySlug()
SDK->>Strapi: GET /api/articles?filters[slug][$eq]=my-post
alt Success
Strapi-->>SDK: 200 OK + Data
SDK-->>DataLayer: Article Data
DataLayer-->>Page: Article Data
Page-->>User: Render Article
else Network Error
Strapi-->>SDK: Connection Failed
SDK->>SDK: throw StrapiError
SDK-->>ErrorBoundary: Error Bubbles Up
ErrorBoundary-->>User: "Something went wrong"
else Not Found
Strapi-->>SDK: 200 OK + Empty Array
SDK-->>DataLayer: null
DataLayer-->>Page: null
Page->>Page: notFound()
Page-->>User: 404 Page
end
flowchart TB
subgraph "Presentation Layer"
Page[Page Component]
ErrorUI[Error Boundary]
NotFoundUI[Not Found Page]
end
subgraph "Data Layer"
DataFetcher[Data Fetcher Functions]
end
subgraph "SDK Layer"
SDK[Strapi SDK]
StrapiError[StrapiError Class]
end
subgraph "External"
API[Strapi API]
end
Page --> DataFetcher
DataFetcher --> SDK
SDK --> API
SDK -.->|throws| StrapiError
StrapiError -.->|catches| ErrorUI
Page -.->|notFound()| NotFoundUI
style StrapiError fill:#ff6b6b,stroke:#333
style ErrorUI fill:#ffd93d,stroke:#333
style NotFoundUI fill:#6bcb77,stroke:#333
| Property | Type | Description |
|---|---|---|
name | string | Always "StrapiError" |
message | string | Human-readable error message |
contentType | string | The Strapi content type that failed |
cause | unknown | Original error for debugging |
flowchart LR
subgraph "Error Scenarios"
A[Network Error] -->|No connection| E1[StrapiError]
B[404 Not Found] -->|Content missing| E2[Return null]
C[401 Unauthorized] -->|Bad token| E3[StrapiError]
D[500 Server Error] -->|Strapi down| E4[StrapiError]
end
| Scenario | Cause | SDK Behavior | Recommended Handling |
|---|---|---|---|
| Network Error | Server unreachable | Throws StrapiError | Show error boundary |
| Content Not Found | Empty result | Returns null | Call notFound() |
| Unauthorized | Invalid/missing token | Throws StrapiError | Redirect to login |
| Server Error | Strapi crashed | Throws StrapiError | Show error boundary |
Let errors bubble up to Next.js error boundaries. This is the cleanest approach.
Data Layer (landing-page.ts):
import { fetchSingleType } from "@/lib/strapi-sdk";
import type { TLandingPage } from "@/types";
export async function getLandingPageData(): Promise<TLandingPage> {
return fetchSingleType<TLandingPage>("landing-page");
}
Page Component (page.tsx):
import { notFound } from "next/navigation";
import { strapiApi } from "@/data/index";
export default async function Home() {
const data = await strapiApi.landingPage.getLandingPageData();
if (!data) notFound();
return (
<div className="flex min-h-screen">
<BlockRenderer blocks={data.blocks} />
</div>
);
}
Global Error Boundary (global-error.tsx):
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<div className="flex min-h-screen flex-col items-center justify-center">
<h1 className="text-2xl font-bold mb-4">Something went wrong</h1>
<p className="text-gray-600 mb-4">{error.message}</p>
<button
onClick={() => reset()}
className="px-4 py-2 bg-primary text-white rounded hover:opacity-90"
>
Try again
</button>
</div>
</body>
</html>
);
}
For more control, catch errors at the data layer.
// src/data/articles.ts
import { fetchCollectionTypeBySlug, StrapiError } from "@/lib/strapi-sdk";
import type { TArticle } from "@/types";
type Result<T> =
| { data: T; error: null }
| { data: null; error: string };
export async function getArticleBySlug(slug: string): Promise<Result<TArticle>> {
try {
const data = await fetchCollectionTypeBySlug<TArticle>("articles", slug);
if (!data) {
return { data: null, error: "Article not found" };
}
return { data, error: null };
} catch (err) {
const message = err instanceof StrapiError
? err.message
: "Failed to load article";
return { data: null, error: message };
}
}
Usage:
const { data, error } = await getArticleBySlug(slug);
if (error) {
// Handle error
}
if (!data) {
notFound();
}
type FetchResult<T> =
| { status: "success"; data: T }
| { status: "not_found" }
| { status: "error"; error: StrapiError };
export async function getArticleBySlug(slug: string): Promise<FetchResult<TArticle>> {
try {
const data = await fetchCollectionTypeBySlug<TArticle>("articles", slug);
if (!data) {
return { status: "not_found" };
}
return { status: "success", data };
} catch (err) {
return {
status: "error",
error: err instanceof StrapiError ? err : new StrapiError("Unknown error", "articles", err)
};
}
}
// Usage with exhaustive type checking
const result = await getArticleBySlug(slug);
switch (result.status) {
case "success":
return <Article data={result.data} />;
case "not_found":
notFound();
case "error":
throw result.error;
}
We organize API calls through a centralized data layer:
flowchart LR
subgraph "Data Layer"
Index[index.ts]
LP[landing-page.ts]
Articles[articles.ts]
Other[...]
end
Index --> LP
Index --> Articles
Index --> Other
Page[Page Components] --> Index
Export structure (data/index.ts):
import { getLandingPageData } from "./landing-page";
export const strapiApi = {
landingPage: {
getLandingPageData
}
}
| Variable | Description | Required |
|---|---|---|
NEXT_PUBLIC_STRAPI_URL | Strapi backend URL | Yes |
STRAPI_API_TOKEN | API token for authenticated requests | No |
Always provide generic types to fetch functions:
// Good
fetchSingleType<TLandingPage>("landing-page")
// Bad
fetchSingleType("landing-page")
Don't wrap calls in try/catch unless you need custom handling.
Keep API calls in src/data/ for organization.
The SDK handles draft mode automatically - no manual configuration needed.
Use createAuthenticatedClient for user-specific requests.
// Good: Custom error with context
throw new StrapiError(
`Failed to fetch collection "${collectionName}"`,
collectionName,
originalError
);
// Bad: Generic error
throw new Error("API call failed");
// Good: Keep the cause for debugging
catch (error) {
throw new StrapiError(message, contentType, error);
}
// Bad: Lose the original error
catch (error) {
throw new StrapiError(message, contentType);
}
flowchart TD
A[Fetch Result] --> B{Has Data?}
B -->|Yes| C[Return Data]
B -->|No - Empty Result| D[Return null / notFound]
B -->|No - Exception| E[Throw StrapiError]
style D fill:#6bcb77,stroke:#333
style E fill:#ff6b6b,stroke:#333
// Not Found = expected case, return null
if (!data) return null;
// Error = unexpected case, throw
throw new StrapiError(...);
catch (error) {
// Log for monitoring
console.error(`[Strapi] Failed to fetch ${contentType}:`, error);
// Re-throw with context
throw new StrapiError(message, contentType, error);
}
function isStrapiError(error: unknown): error is StrapiError {
return error instanceof StrapiError;
}
// Usage
catch (error) {
if (isStrapiError(error)) {
console.log(`Content type: ${error.contentType}`);
}
}
| Feature | Basic | Advanced |
|---|---|---|
| Custom Error Class | No | Yes (StrapiError) |
| Error Context | Limited | Full (contentType, cause) |
| Type Safety | No | Yes (generics) |
| Draft Mode | No | Yes (automatic) |
| Auth Handling | Manual | Automatic |
| Debugging | Harder | Easier |
| Code Complexity | Low | Medium |
| Production Ready | No | Yes |
flowchart TB
subgraph "Choose Your Approach"
A[Beginner?] -->|Yes| B[Use Simple SDK]
A -->|No| C[Use Advanced SDK]
B --> D[Add try/catch as needed]
C --> E[Let errors propagate to boundaries]
D --> F[Learn patterns]
E --> G[Production ready]
F -->|Graduate to| C
end
Key Takeaways: