This guide walks you through integrating Vite's lightning-fast build tooling with Strapi v5's headless CMS. You'll learn setup procedures, API consumption patterns, and build a working project from scratch

Vite is a frontend build tool that takes a fundamentally different approach than traditional bundlers. Instead of bundling your entire application before serving it, Vite leverages native ES modules in the browser to serve source code on-demand.
The tool consists of two major components: a development server that provides instant startup and hot module replacement, and a build command that uses Rollup for optimized production bundles. During development, Vite pre-bundles dependencies using esbuild—which pre-bundles dependencies 10-100x faster than JavaScript-based bundlers—while serving your application code directly to the browser as native ES modules on-demand.
This architecture means server startup time stays consistent regardless of application size. When you edit a file, Vite only invalidates the specific module chain affected, keeping HMR fast even in large codebases.
Combining Vite's build performance with Strapi's content management features creates a development experience optimized for content-driven applications. Here's what makes this pairing effective:
Before starting, verify your environment meets these requirements:
You should be comfortable with JavaScript/TypeScript, REST APIs, and basic command line operations.
Start by scaffolding a new Strapi v5 project. Open your terminal and run:
npx create-strapi@latest my-strapi-backendThe CLI walks you through an interactive setup where you select your database and configure credentials. Once installation completes, start the development server:
cd my-strapi-backend
npm run developStrapi runs on port 1337 by default. The admin panel opens at http://localhost:1337/admin, where you create your first administrator account.
For this integration, create a simple content type to work with. Navigate to the Content-Type Builder in the admin panel and create a collection type called "Article" with these fields:
title (Text, required)content (Rich Text)slug (UID, attached to title)publishedAt (DateTime)After creating the content type, add a few sample articles through the Content Manager.
In a separate terminal, scaffold a new Vite project:
npm create vite@latest my-vite-frontendSelect your preferred framework (React, Vue, or Svelte) and variant (JavaScript or TypeScript). This guide uses React with TypeScript for examples, but the patterns apply to any framework.
Install dependencies and start the development server:
cd my-vite-frontend
npm install
npm run devVite runs on port 5173 by default. You now have two development servers running: Strapi on 1337 and Vite on 5173.
This is where most integration issues occur. Strapi requires explicit CORS configuration to allow requests from your Vite frontend, so your development server on port 5173 needs to be added to the allowed origins in Strapi's middleware configuration.
Open config/middlewares.js in your Strapi project and replace its contents:
module.exports = [
'strapi::logger',
'strapi::errors',
'strapi::security',
{
name: 'strapi::cors',
config: {
origin: ['http://localhost:5173'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
headers: ['Content-Type', 'Authorization', 'Origin', 'Accept'],
keepHeaderOnError: true,
},
},
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];The origin array must include the exact URL of your Vite development server. For production deployments, add your production frontend URL to this array.
Restart the Strapi server for the changes to take effect.
Create a .env file in your Vite project root. Remember that only variables prefixed with VITE_ are exposed to client-side code:
VITE_API_URL=http://localhost:1337
VITE_API_TOKEN=your_api_token_hereTo create an API token, go to Settings → API Tokens in the Strapi admin panel. Create a new token with "Read-only" permissions for public content fetching. This token grants access to your content without exposing admin credentials.
Access these variables in your code using import.meta.env:
const API_URL = import.meta.env.VITE_API_URL;A proxy configuration simplifies API calls during development by routing requests through Vite's server. This approach eliminates CORS issues entirely for development:
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:1337',
changeOrigin: true,
secure: false,
ws: true,
}
},
cors: true,
}
})With proper Vite proxy configuration in vite.config.js, fetch calls to /api/articles automatically route to http://localhost:1337/api/articles during development. However, this development convenience requires careful CORS configuration in Strapi's config/middlewares.js to allow requests from the Vite development server (http://localhost:5173). For production, the proxy becomes unnecessary as both frontend and backend share the same domain or you must use explicit API URL configuration instead.
Build a centralized API service to handle all Strapi communication. Create src/services/api.ts:
// src/services/api.ts
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:1337';
const API_TOKEN = import.meta.env.VITE_API_TOKEN;
interface StrapiResponse<T> {
data: T;
meta: {
pagination?: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
interface Article {
id: number;
documentId: string;
title: string;
content: string;
slug: string;
publishedAt: string;
}
export async function fetchArticles(): Promise<Article[]> {
const response = await fetch(`${API_URL}/api/articles?populate=*`, {
headers: {
'Authorization': `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch articles: ${response.statusText}`);
}
const result: StrapiResponse<Article[]> = await response.json();
return result.data;
}
export async function fetchArticleBySlug(slug: string): Promise<Article | null> {
const response = await fetch(
`${API_URL}/api/articles?filters[slug][$eq]=${slug}&populate=*`,
{
headers: {
'Authorization': `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
throw new Error(`Failed to fetch article: ${response.statusText}`);
}
const result: StrapiResponse<Article[]> = await response.json();
return result.data[0] || null;
}Note on Strapi v5 response structure: Data attributes exist directly on the object—access article.title instead of article.attributes.title. This flattened structure simplifies data handling compared to v4.
Create a component that fetches and displays articles from Strapi v5. Here's a React example using Vite:
// src/components/ArticleList.tsx
import { useEffect, useState } from 'react';
import { fetchArticles } from '../services/api';
interface Article {
id: number;
documentId: string;
title: string;
content: string;
slug: string;
publishedAt: string;
}
export function ArticleList() {
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadArticles = async () => {
try {
const data = await fetchArticles();
setArticles(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load articles');
} finally {
setLoading(false);
}
};
loadArticles();
}, []);
if (loading) return <div>Loading articles...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div className="articles-grid">
{articles.map((article) => (
<article key={article.documentId}>
<h2>{article.title}</h2>
<time dateTime={article.publishedAt}>
{new Date(article.publishedAt).toLocaleDateString()}
</time>
<div dangerouslySetInnerHTML={{ __html: article.content }} />
</article>
))}
</div>
);
}When working with images from Strapi's Media Library, file URLs are relative paths. Construct full URLs by prepending the Strapi base URL:
interface MediaFile {
id: number;
name: string;
url: string;
mime: string;
size: number;
formats?: {
thumbnail?: { url: string };
small?: { url: string };
medium?: { url: string };
large?: { url: string };
};
}
function getImageUrl(media: MediaFile, size?: 'thumbnail' | 'small' | 'medium' | 'large'): string {
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:1337';
if (!media || !media.url) {
console.warn('Invalid media object provided to getImageUrl');
return '';
}
let imagePath = media.url;
// Use the requested size format if available
if (size && media.formats?.[size]?.url) {
imagePath = media.formats[size].url;
}
// Construct full URL by prepending the Strapi base URL
return `${baseUrl}${imagePath}`;
}This pattern lets you request appropriately sized images based on display context—thumbnails for lists, larger formats for detail views.
Let's build a functional blog that demonstrates the full integration. This project uses React with React Router, but the concepts translate to Vue Router or SvelteKit.
vite-strapi-blog/
├── src/
│ ├── components/
│ │ ├── ArticleCard.tsx
│ │ ├── ArticleDetail.tsx
│ │ └── Layout.tsx
│ ├── services/
│ │ └── api.ts
│ ├── hooks/
│ │ └── useArticles.ts
│ ├── pages/
│ │ ├── Home.tsx
│ │ └── Article.tsx
│ ├── App.tsx
│ └── main.tsx
├── vite.config.ts
├── .env
└── package.jsonCreate a reusable hook for article fetching with loading and error states:
// src/hooks/useArticles.ts
import { useEffect, useState } from 'react';
import { fetchArticles, fetchArticleBySlug } from '../services/api';
interface Article {
id: number;
documentId: string;
title: string;
content: string;
slug: string;
publishedAt: string;
coverImage?: {
url: string;
formats?: Record<string, { url: string }>;
};
}
export function useArticles() {
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const data = await fetchArticles();
if (!cancelled) {
setArticles(data);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err : new Error('Unknown error'));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
load();
return () => {
cancelled = true;
};
}, []);
return { articles, loading, error };
}
export function useArticle(slug: string) {
const [article, setArticle] = useState<Article | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const data = await fetchArticleBySlug(slug);
if (!cancelled) {
setArticle(data);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err : new Error('Unknown error'));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
load();
return () => {
cancelled = true;
};
}, [slug]);
return { article, loading, error };
}Expand the API service to support Strapi v5's query parameters including filtering operators ($eq, $ne, $lt, $lte, $gt, $gte, $in, $notIn, $contains, $notContains, $containsi, $startsWith, $endsWith, $null, $notNull), the populate parameter for explicit relation fetching, pagination options (page/pageSize or start/limit), sorting capabilities, field selection, locale parameters, and draft/publish status control.
// src/services/api.ts
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:1337';
const API_TOKEN = import.meta.env.VITE_API_TOKEN;
interface FetchOptions {
page?: number;
pageSize?: number;
sort?: string;
filters?: Record<string, string>;
}
export async function fetchArticles(options: FetchOptions = {}) {
const { page = 1, pageSize = 10, sort = 'publishedAt:desc', filters = {} } = options;
const params = new URLSearchParams({
'populate': '*',
'pagination[page]': page.toString(),
'pagination[pageSize]': pageSize.toString(),
'sort': sort,
});
// Add filters
Object.entries(filters).forEach(([key, value]) => {
params.append(`filters[${key}][$eq]`, value);
});
const response = await fetch(`${API_URL}/api/articles?${params}`, {
headers: {
'Authorization': `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}// Fetch only published articles from 2024
const articles = await fetchArticles({
filters: {
'publishedAt[$gte]': '2024-01-01',
'status': 'published',
},
sort: 'publishedAt:desc',
pageSize: 5,
});If your content type uses Dynamic Zones for flexible content blocks, you must explicitly populate them in your API requests and implement conditional rendering logic in your frontend:
interface DynamicComponent {
__component: string;
id: number;
[key: string]: unknown;
}
interface RichTextBlock extends DynamicComponent {
__component: 'blocks.rich-text';
content: string;
}
interface MediaBlock extends DynamicComponent {
__component: 'blocks.media';
file: { url: string };
caption?: string;
}
type ContentBlock = RichTextBlock | MediaBlock;
function renderDynamicZone(components: ContentBlock[]) {
return components.map((component) => {
switch (component.__component) {
case 'blocks.rich-text':
return <div key={component.id} dangerouslySetInnerHTML={{ __html: (component as RichTextBlock).content }} />;
case 'blocks.media':
const media = component as MediaBlock;
return <img key={component.id} src={getImageUrl(media.file)} alt={media.caption || ''} />;
default:
return null;
}
});
}The __component discriminator field tells you which component type to render, enabling type-safe handling of varied content structures.
For production deployment, update your environment variables and build configuration:
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
},
},
},
},
})# .env.production
VITE_API_URL=https://your-strapi-production-url.com
VITE_API_TOKEN=your_production_api_tokenBuild the production bundle:
npm run buildDeploy the dist folder to any static hosting service—Vercel, Netlify, or a CDN. The Strapi backend deploys separately to Node.js hosting. Strapi Cloud or third-party providers like Railway or Render work well for the backend.
With Vite handling your frontend builds and Strapi managing your content, you have a modern stack optimized for content-driven applications. The patterns covered here—API service abstraction, proper CORS configuration, and typed responses—scale from simple blogs to complex multi-content-type applications. Explore Strapi's plugin ecosystem to extend functionality as your project grows.
If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and Vite documentation.
CORS errors occur because Strapi blocks cross-origin requests by default. Configure CORS in your Strapi project's config/middlewares.js file by adding a CORS middleware configuration with http://localhost:5173 in the origin array. Ensure the URL matches exactly—including the protocol and port. Restart Strapi after making changes. Alternatively, for development, you can configure Vite's proxy in vite.config.js to route API requests through Vite's server, bypassing CORS entirely during local development.
Vite only exposes environment variables prefixed with VITE_ to client-side code. Rename variables like API_URL to VITE_API_URL in your .env file. Access them using import.meta.env.VITE_API_URL instead of process.env. Always restart the Vite development server after modifying environment variables—changes aren't picked up automatically. Keep sensitive credentials server-side only; API tokens in client code are visible to users.
Strapi v5 doesn't automatically include related content in API responses—you must explicitly request it using the populate parameter. Add ?populate=* to fetch all first-level relations, or specify individual fields like ?populate=author,categories. For nested relations, use bracket notation: ?populate[author][populate]=avatar. This approach gives you control over response payload size, which matters for performance when content types have many relations.
Yes, Strapi v5 includes a GraphQL plugin that exposes a /graphql endpoint. Enable it in config/plugins.js with configuration options for depth limits and pagination settings.
In Strapi v5, GraphQL queries use documentId (string type) instead of numeric IDs. The GraphQL approach works well when you need precise control over response shape or want to fetch data from multiple content types in a single request without over-fetching.
Keep frontend and backend in separate directories, each with their own package.json and dependencies. This structure enables independent deployment—Vite builds to static files for CDN hosting while Strapi runs on Node.js hosting. During development, run both servers concurrently (Strapi on 1337, Vite on 5173) and connect them via environment variables. Avoid monorepo configurations unless you have specific tooling requirements, as separate deployments simplify scaling and maintenance.