Building a SaaS landing page shouldn't require stitching together a dozen services and hoping they talk to each other. Yet that's exactly where most full-stack developers end up: juggling a frontend framework, a separate Content Management System (CMS), authentication logic, and deployment configs across fragmented toolchains.
This guide walks you through building a production-ready SaaS website using Next.js on the frontend and Strapi as a headless CMS backend. You'll set up dynamic content types for pricing plans, features, testimonials, and blog posts, wire them to a responsive Next.js App Router frontend, add token-based authentication, and deploy the whole thing to Vercel and Railway.
Before diving in, make sure your local environment is ready:
You should be comfortable with React component patterns, JavaScript ES6+ syntax, and basic REST API concepts. Familiarity with Tailwind CSS helps but isn't strictly required.
Start by scaffolding a new Next.js project with TypeScript, Tailwind CSS, and the App Router enabled. According to the Next.js installation guide, the CLI handles all the initial configuration:
1npx create-next-app@latest frontend --typescript --tailwind --appNavigate into the project and install the dependencies you'll need for API communication, authentication, and UI:
1cd frontend
2npm install next-auth@latest axios react-icons
3npm install -D @types/react @types/nodeVerify everything works:
1npm run devOpen http://localhost:3000 in your browser. You should see the default Next.js welcome page.
Based on the Next.js project structure guidelines, organize your frontend like this:
1frontend/
2├── app/
3│ ├── (auth)/
4│ │ ├── login/page.tsx
5│ │ └── register/page.tsx
6│ ├── (dashboard)/
7│ │ └── dashboard/page.tsx
8│ ├── blog/[slug]/page.tsx
9│ ├── layout.tsx
10│ └── page.tsx
11├── components/
12│ ├── HeroSection.tsx
13│ ├── FeaturesGrid.tsx
14│ ├── PricingTable.tsx
15│ ├── TestimonialSlider.tsx
16│ ├── BlogPreview.tsx
17│ ├── Navbar.tsx
18│ └── Footer.tsx
19├── lib/
20│ └── api.ts
21├── types/
22│ └── strapi.ts
23└── middleware.tsRoute groups wrapped in parentheses, (auth) and (dashboard), let you apply different layouts to public and authenticated sections without affecting URL structure. This is one of the App Router's more useful organizational features.
Open a separate terminal window and create a new Strapi project:
1npx create-strapi-app@latest my-projectThe --quickstart flag configures SQLite for local development only. SQLite should never be used in production. For production deployment, you'll need to configure PostgreSQL through environment variables and ensure it's properly set up before deployment.
Once the installation completes, Strapi opens the Admin Panel at http://localhost:1337/admin. According to Strapi's admin panel setup guide, create your admin account with a secure email and password. This first registered user automatically receives the Super Admin role.
Take a few minutes to explore the interface: the Content-Type Builder for defining data structures, the Content Manager for creating entries, and the Media Library for managing uploads.
Create a .env.local file in your frontend directory:
1# Next.js Frontend Environment Variables
2STRAPI_URL=http://localhost:1337
3NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
4NEXT_PUBLIC_SITE_URL=http://localhost:3000
5STRAPI_API_TOKEN=your_strapi_api_token_hereAnd in your Strapi project root, configure your .env:
1# Strapi Backend Environment Variables
2NODE_ENV=development
3DATABASE_URL=postgresql://user:password@localhost:5432/strapi
4APP_KEYS=key1,key2,key3,key4
5API_TOKEN_SALT=your-api-token-salt
6ADMIN_JWT_SECRET=your-admin-jwt-secret
7JWT_SECRET=your-jwt-secret
8TRANSFER_TOKEN_SALT=your-transfer-token-saltGenerate an API token in Strapi under Settings → API Tokens → Create new API Token. According to Strapi's API token documentation, choose "Read-only" for now since your frontend only needs to fetch data. Paste the token into STRAPI_API_TOKEN.
Note the distinction: variables prefixed with NEXT_PUBLIC_ are exposed to the browser. Variables without the prefix remain server-side only, keeping your API token safe. The Next.js environment variables guide covers this in detail.
This is where you define the data architecture for your SaaS site. Start with the backend before touching the frontend; it gives you clear API contracts to work against.
Open the Content-Type Builder and create these Collection Types:
Feature
| Field Name | Type | Required |
|---|---|---|
| title | Short Text | Yes |
| description | Rich Text | Yes |
| icon | Media | Yes |
| order | Number | Yes |
PricingPlan
| Field Name | Type | Required |
|---|---|---|
| planName | Text (Short) | Yes |
| price | Decimal | Yes |
| currency | Enumeration (USD, EUR, GBP) | Yes |
| billingCycle | Enumeration (monthly, yearly) | Yes |
| description | Rich Text | No |
| features | Component (repeatable: PlanFeature) | Yes |
| isPopular | Boolean | No |
| ctaButton | Component (CTAButton) | Yes |
| trialDays | Number | No |
For the features field, create a reusable component called PlanFeature with a name (Text) and included (Boolean) field.
According to Strapi's content modeling best practices, components work well here because pricing features are tightly coupled to their parent plan and don't need independent API endpoints. Unlike relations which should be used when data exists as independent entities or multiple content types reference the same data, components are ideal for data that is tightly coupled to the parent and managed inline by content editors.
Testimonial
| Field Name | Type | Required |
|---|---|---|
| authorName | Short Text | Yes |
| authorRole | Short Text | Yes |
| authorImage | Media | No |
| quote | Long Text | Yes |
| rating | Number | Yes |
BlogPost
According to Strapi's Content-Type Builder Documentation, a BlogPost collection type for SaaS websites should include the following configured fields:
| Field Name | Type | Required |
|---|---|---|
| title | Text (short) | Yes |
| slug | UID (linked to title) | Yes |
| content | Rich Text | Yes |
| excerpt | Text (long) | Yes |
| featuredImage | Media (single image) | Yes |
| publishedAt | DateTime | Yes |
| author | Relation (many-to-one → User) | Yes |
| category | Relation (many-to-one) | No |
This structure aligns with Strapi's content modeling best practices, where required fields (title, slug, content, excerpt, featuredImage, publishedAt, author) ensure functional blog posts, while optional relations like category provide flexible content organization without enforcement.
The UID field creates unique, URL-friendly slugs that serve as alternative identifiers for your content, which you'll use for dynamic blog routes and API endpoints.
Create a HeroSection Single Type:
| Field Name | Type | Required |
|---|---|---|
| heading | Short Text | Yes |
| subheading | Long Text | Yes |
| ctaPrimary | Short Text | Yes |
| ctaSecondary | Short Text | No |
| heroImage | Media | Yes |
Single Types generate single-entry endpoints (/api/hero-section) rather than list endpoints, which is exactly right for unique page content.
Using the Content Manager, add at least four features, three pricing plans (Free, Pro, Enterprise), three testimonials, two blog posts, and one Hero Section entry. Having real data in Strapi makes frontend development dramatically faster because you can see actual API responses instead of guessing at data shapes.
Navigate to Settings → Users & Permissions plugin → Roles → Public. For each collection type (such as Features, PricingPlans, Testimonials, and BlogPosts), enable the find and findOne permissions to allow unauthenticated users to retrieve lists and individual entries. For single types (such as HeroSection), enable the find permission to allow public access to retrieve the single-entry content. According to Strapi's Users & Permissions documentation, these granular permission configurations control which API endpoints are accessible without authentication.
A common mistake, and one most teams learn the hard way, is forgetting this step entirely. If your frontend returns 403 errors, check permissions first. By default, all API endpoints are protected and require authentication. Developers must explicitly enable public access through the Users & Permissions plugin configuration.
For the Authenticated role in Strapi's Users & Permissions configuration, enable full Create, Read, Update, Delete (CRUD) access (find, findOne, create, update, delete operations) for logged-in users so they can interact with content through API endpoints consumed by the dashboard.
In frontend/lib/api.ts, build a type-safe fetch wrapper:
1const baseUrl = process.env.STRAPI_URL || 'http://localhost:1337';
2
3export async function fetchAPI<T>(path: string, options: RequestInit = {}): Promise<T> {
4 const url = new URL(`/api${path}`, baseUrl);
5
6 const response = await fetch(url, {
7 headers: {
8 'Content-Type': 'application/json',
9 Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
10 ...options.headers,
11 },
12 ...options,
13 });
14
15 if (!response.ok) {
16 throw new Error(`API error: ${response.status} ${response.statusText}`);
17 }
18
19 return response.json();
20}
21
22export async function getHeroSection() {
23 return fetchAPI('/hero-section?populate=*');
24}
25
26export async function getFeatures() {
27 return fetchAPI('/features?populate=*&sort=order:asc');
28}
29
30export async function getPricingPlans() {
31 return fetchAPI('/pricing-plans?populate=*');
32}
33
34export async function getTestimonials() {
35 return fetchAPI('/testimonials?populate=*');
36}
37
38export async function getBlogPosts() {
39 return fetchAPI('/blog-posts?populate=*&sort=publishedDate:desc');
40}
41
42export async function getBlogPostBySlug(slug: string) {
43 return fetchAPI(`/blog-posts?filters[slug][$eq]=${slug}&populate=*`);
44}This function integrates with Strapi's REST API filtering system. The filters[slug][$eq] operator performs exact matching on the slug field, while populate=* retrieves all related fields and relations as specified in the Strapi populate documentation.
The populate=* parameter tells Strapi to include all first-level relations and media fields. According to the Strapi populate documentation, deeper population of nested structures requires more granular configuration using array syntax like populate[author][populate][0]=avatar for multi-level relations. For filtering, the Strapi filters documentation specifies operators including $eq, $ne, $lt, $lte, $gt, $gte, $in, $contains, and $containsi (case-insensitive).
These query parameters work together to create precise API requests. For example, filters[status][$eq]=published returns only published content, while sort=publishedAt:desc orders results by publication date in descending order.
In frontend/types/strapi.ts, define the response shapes based on Strapi's REST API documentation:
1export interface StrapiResponse<T> {
2 data: T;
3 meta: {
4 pagination?: {
5 page: number;
6 pageSize: number;
7 pageCount: number;
8 total: number;
9 };
10 };
11}
12
13export interface Feature {
14 id: number;
15 title: string;
16 description: string;
17 icon?: { url: string; alternativeText: string };
18 order: number;
19}
20
21export interface PricingPlan {
22 id: number;
23 planName: string;
24 price: number;
25 billingCycle: 'monthly' | 'yearly';
26 features: { name: string; included: boolean }[];
27 isPopular: boolean;
28 ctaText: string;
29 ctaLink: string;
30}
31
32export interface Testimonial {
33 id: number;
34 authorName: string;
35 authorRole: string;
36 authorImage?: { url: string; alternativeText: string };
37 quote: string;
38 rating: number;
39}
40
41export interface BlogPost {
42 id: number;
43 title: string;
44 slug: string;
45 content: string;
46 excerpt: string;
47 featuredImage: { url: string; alternativeText: string };
48 publishedAt: string;
49 author?: { id: number; username: string; email: string };
50}Defining these interfaces catches data mismatches at compile time rather than in production. It's one of those things that feels like extra work until it saves you from a 2 AM debugging session.
In frontend/app/page.tsx, fetch all data server-side using async Server Components. This approach, recommended by the Next.js data fetching patterns guide, eliminates client-side state management for initial data loading:
1import {
2 getHeroSection,
3 getFeatures,
4 getPricingPlans,
5 getTestimonials,
6 getBlogPosts,
7} from '@/lib/api';
8import type { StrapiResponse } from '@/types/strapi';
9import HeroSection from '@/components/HeroSection';
10import FeaturesGrid from '@/components/FeaturesGrid';
11import PricingTable from '@/components/PricingTable';
12import TestimonialSlider from '@/components/TestimonialSlider';
13import BlogPreview from '@/components/BlogPreview';
14
15interface PageData {
16 hero: any;
17 features: any[];
18 pricing: any[];
19 testimonials: any[];
20 blogPosts: any[];
21}
22
23async function getPageData(): Promise<PageData> {
24 try {
25 const [heroRes, featuresRes, pricingRes, testimonialsRes, blogsRes] =
26 await Promise.all([
27 getHeroSection(),
28 getFeatures(),
29 getPricingPlans(),
30 getTestimonials(),
31 getBlogPosts(),
32 ]);
33
34 return {
35 hero: heroRes.data,
36 features: featuresRes.data || [],
37 pricing: pricingRes.data || [],
38 testimonials: testimonialsRes.data || [],
39 blogPosts: blogsRes.data || [],
40 };
41 } catch (error) {
42 console.error('Failed to fetch page data:', error);
43 return {
44 hero: null,
45 features: [],
46 pricing: [],
47 testimonials: [],
48 blogPosts: [],
49 };
50 }
51}
52
53export default async function HomePage() {
54 const pageData = await getPageData();
55
56 return (
57 <main>
58 {pageData.hero && <HeroSection data={pageData.hero} />}
59 <FeaturesGrid data={pageData.features} />
60 <PricingTable data={pageData.pricing} />
61 <TestimonialSlider data={pageData.testimonials} />
62 <BlogPreview data={pageData.blogPosts} />
63 </main>
64 );
65}Using Promise.all fires all five API requests concurrently rather than sequentially. On a page with this many data sources, that difference is noticeable.
Each component pulls data from Strapi and renders it with Tailwind CSS. Here's PricingTable.tsx as a representative example:
1import type { PricingPlan } from '@/types/strapi';
2
3export default function PricingTable({ data }: { data: PricingPlan[] }) {
4 return (
5 <section className="py-20 px-4">
6 <h2 className="text-3xl font-bold text-center mb-12">Pricing Plans</h2>
7 <div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-6xl mx-auto">
8 {data.map((plan) => (
9 <div
10 key={plan.id}
11 className={`rounded-2xl p-8 border ${
12 plan.isPopular
13 ? 'border-blue-500 shadow-lg scale-105'
14 : 'border-gray-200'
15 } hover:shadow-xl transition-shadow duration-300`}
16 >
17 {plan.isPopular && (
18 <span className="bg-blue-500 text-white text-sm px-3 py-1 rounded-full">
19 Most Popular
20 </span>
21 )}
22 <h3 className="text-xl font-semibold mt-4">{plan.planName}</h3>
23 <p className="text-4xl font-bold mt-2">
24 ${plan.price}
25 <span className="text-base font-normal text-gray-500">
26 /{plan.billingCycle}
27 </span>
28 </p>
29 <ul className="mt-6 space-y-3">
30 {plan.features.map((feature, i) => (
31 <li key={i} className="flex items-center gap-2">
32 <span className={feature.included ? 'text-green-500' : 'text-gray-300'}>
33 {feature.included ? '✓' : '✗'}
34 </span>
35 {feature.name}
36 </li>
37 ))}
38 </ul>
39 <a
40 href={plan.ctaLink}
41 className="mt-8 block text-center bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 transition-colors duration-300"
42 >
43 {plan.ctaText}
44 </a>
45 </div>
46 ))}
47 </div>
48 </section>
49 );
50}And here's a HeroSection.tsx component to render your Single Type content:
1import Image from 'next/image';
2
3interface HeroData {
4 heading: string;
5 subheading: string;
6 ctaPrimary: string;
7 ctaSecondary?: string;
8 heroImage: { url: string; alternativeText: string };
9}
10
11export default function HeroSection({ data }: { data: HeroData }) {
12 return (
13 <section className="flex flex-col md:flex-row items-center gap-12 px-4 py-24 max-w-6xl mx-auto">
14 <div className="flex-1 text-center md:text-left">
15 <h1 className="text-5xl font-bold tracking-tight">{data.heading}</h1>
16 <p className="mt-6 text-lg text-gray-600">{data.subheading}</p>
17 <div className="mt-8 flex gap-4 justify-center md:justify-start">
18 <a href="#pricing" className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors">
19 {data.ctaPrimary}
20 </a>
21 {data.ctaSecondary && (
22 <a href="#features" className="border border-gray-300 px-6 py-3 rounded-lg hover:bg-gray-50 transition-colors">
23 {data.ctaSecondary}
24 </a>
25 )}
26 </div>
27 </div>
28 <div className="flex-1">
29 <Image
30 src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${data.heroImage.url}`}
31 alt={data.heroImage.alternativeText || data.heading}
32 width={600}
33 height={400}
34 priority
35 className="rounded-xl w-full h-auto"
36 />
37 </div>
38 </section>
39 );
40}Here's the FeaturesGrid.tsx component for displaying feature cards in a responsive grid:
1import Image from 'next/image';
2import type { Feature } from '@/types/strapi';
3
4export default function FeaturesGrid({ data }: { data: Feature[] }) {
5 return (
6 <section id="features" className="py-20 px-4 bg-gray-50">
7 <h2 className="text-3xl font-bold text-center mb-4">Everything You Need</h2>
8 <p className="text-center text-gray-600 mb-12 max-w-2xl mx-auto">
9 Built for teams that want to move fast without compromising quality.
10 </p>
11 <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 max-w-6xl mx-auto">
12 {data.map((feature) => (
13 <div
14 key={feature.id}
15 className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow duration-300"
16 >
17 {feature.icon && (
18 <Image
19 src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${feature.icon.url}`}
20 alt={feature.icon.alternativeText || feature.title}
21 width={48}
22 height={48}
23 className="mb-4"
24 />
25 )}
26 <h3 className="font-semibold text-lg mb-2">{feature.title}</h3>
27 <p className="text-gray-600 text-sm">{feature.description}</p>
28 </div>
29 ))}
30 </div>
31 </section>
32 );
33}The remaining components — TestimonialSlider, BlogPreview, Navbar, and Footer — follow the same pattern: accept typed props, render with Tailwind utility classes, and use responsive grid or flex layouts. The TestimonialSlider needs 'use client' since it manages carousel state with useState.
For the Navbar, use Tailwind's hidden md:flex pattern combined with a state-driven mobile menu. The Headless UI menu component handles accessible dropdown behavior with built-in keyboard navigation and focus management, eliminating the need for custom accessibility logic.
Add frontend/app/blog/[slug]/page.tsx for individual blog posts. The page component fetches data server-side, then renders the article content:
1import { getBlogPostBySlug } from '@/lib/api';
2import { notFound } from 'next/navigation';
3import Image from 'next/image';
4
5interface PageProps {
6 params: { slug: string };
7}
8
9export default async function BlogPostPage({ params }: PageProps) {
10 const { data } = await getBlogPostBySlug(params.slug);
11
12 if (!data || data.length === 0) {
13 notFound();
14 }
15
16 const post = data[0];
17
18 return (
19 <article className="max-w-3xl mx-auto py-16 px-4">
20 <Image
21 src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${post.featuredImage.url}`}
22 alt={post.featuredImage.alternativeText || post.title}
23 width={800}
24 height={400}
25 priority
26 className="rounded-xl w-full object-cover h-auto"
27 sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
28 />
29 <h1 className="text-4xl font-bold mt-8">{post.title}</h1>
30 <p className="text-gray-500 mt-2">
31 {new Date(post.publishedAt).toLocaleDateString()}
32 </p>
33 <div
34 className="prose prose-lg mt-8"
35 dangerouslySetInnerHTML={{ __html: post.content }}
36 />
37 </article>
38 );
39}If your Strapi content includes user-submitted HTML, sanitize it with a library like dompurify before rendering. For content created exclusively by trusted editors through the Strapi Admin Panel, the risk is minimal.
The prose class comes from the Tailwind Typography plugin (@tailwindcss/typography), which automatically styles raw HTML content with readable typography defaults. Install it with npm install @tailwindcss/typography and add it to your Tailwind config's plugins array.
Configure next.config.js to allow images from your Strapi domain. The Next.js Image Optimization documentation recommends remotePatterns over the deprecated domains configuration:
1/** @type {import('next').NextConfig} */
2const nextConfig = {
3 images: {
4 remotePatterns: [
5 {
6 protocol: process.env.NODE_ENV === 'production' ? 'https' : 'http',
7 hostname: process.env.NODE_ENV === 'production'
8 ? new URL(process.env.NEXT_PUBLIC_STRAPI_URL).hostname
9 : 'localhost',
10 port: process.env.NODE_ENV === 'production' ? '' : '1337',
11 pathname: '/uploads/**',
12 },
13 ],
14 },
15};
16
17module.exports = nextConfig;With your components fetching data from Strapi and rendering correctly, it's time to refine the visual layer. Start by customizing your Tailwind configuration with brand-specific design tokens.
In tailwind.config.js, extend the default theme with your SaaS brand colors and font stack:
1// tailwind.config.js
2module.exports = {
3 content: [
4 './app/**/*.{js,ts,jsx,tsx,mdx}',
5 './components/**/*.{js,ts,jsx,tsx,mdx}',
6 ],
7 theme: {
8 extend: {
9 colors: {
10 brand: { 50: '#eff6ff', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8' },
11 },
12 fontFamily: {
13 sans: ['Inter', 'system-ui', 'sans-serif'],
14 },
15 },
16 },
17 plugins: [require('@tailwindcss/typography')],
18};This gives you access to utility classes like bg-brand-600 and text-brand-700 throughout your components, keeping color usage consistent without hardcoding hex values.
Test your layouts across four key breakpoints: 320px (small mobile), 768px (tablet), 1024px (laptop), and 1440px (desktop). Browser DevTools device emulation mode is the fastest way to cycle through these. Pay particular attention to the pricing grid and features grid, which shift from single-column stacks on mobile to multi-column layouts at the md: and lg: breakpoints.
Loading states matter for perceived performance, especially when fetching data from Strapi. Build a skeleton component using Tailwind's animate-pulse utility to show placeholder content while data loads:
1function PricingSkeleton() {
2 return (
3 <div className="animate-pulse rounded-2xl p-8 border border-gray-200">
4 <div className="h-6 bg-gray-200 rounded w-1/3 mb-4" />
5 <div className="h-10 bg-gray-200 rounded w-1/2 mb-6" />
6 <div className="space-y-3">
7 {[...Array(4)].map((_, i) => (
8 <div key={i} className="h-4 bg-gray-200 rounded w-full" />
9 ))}
10 </div>
11 </div>
12 );
13}For smooth anchor navigation between sections (like the hero's CTA scrolling down to #pricing), add the scroll-smooth class to the <html> element in your root layout, or include scroll-behavior: smooth in your global CSS file.
The hover transitions already present in your components — transition-shadow duration-300 on the pricing cards, transition-colors duration-300 on CTA buttons — provide tactile feedback without JavaScript overhead. Stick to specific transition properties rather than transition-all to maintain 60fps performance across devices.
Strapi's Users & Permissions plugin provides JWT authentication through dedicated endpoints (POST /api/auth/local/register and POST /api/auth/local). This requires server actions for registration and login with HttpOnly cookie-based token storage, middleware for route protection, and proper JWT configuration in config/plugins.js with settings for token expiration and secret management.
Create frontend/lib/auth.ts:
1'use server';
2
3import { cookies } from 'next/headers';
4import { redirect } from 'next/navigation';
5
6const STRAPI_URL = process.env.STRAPI_URL;
7
8export async function registerUser(formData: FormData) {
9 const res = await fetch(`${STRAPI_URL}/api/auth/local/register`, {
10 method: 'POST',
11 headers: { 'Content-Type': 'application/json' },
12 body: JSON.stringify({
13 username: formData.get('username'),
14 email: formData.get('email'),
15 password: formData.get('password'),
16 }),
17 });
18
19 if (!res.ok) {
20 const error = await res.json();
21 return { success: false, error: error.error?.message || 'Registration failed' };
22 }
23
24 const data = await res.json();
25
26 cookies().set('auth_token', data.jwt, {
27 httpOnly: true,
28 secure: process.env.NODE_ENV === 'production',
29 sameSite: 'lax',
30 maxAge: 60 * 60 * 24 * 7,
31 path: '/',
32 });
33
34 redirect('/dashboard');
35}
36
37export async function loginUser(formData: FormData) {
38 const res = await fetch(`${STRAPI_URL}/api/auth/local`, {
39 method: 'POST',
40 headers: { 'Content-Type': 'application/json' },
41 body: JSON.stringify({
42 identifier: formData.get('email'),
43 password: formData.get('password'),
44 }),
45 });
46
47 if (!res.ok) {
48 const error = await res.json();
49 return { success: false, error: error.error?.message || 'Invalid credentials' };
50 }
51
52 const data = await res.json();
53
54 cookies().set('auth_token', data.jwt, {
55 httpOnly: true,
56 secure: process.env.NODE_ENV === 'production',
57 sameSite: 'lax',
58 maxAge: 60 * 60 * 24 * 7,
59 path: '/',
60 });
61
62 redirect('/dashboard');
63}
64
65export async function logoutUser() {
66 cookies().delete('auth_token');
67 redirect('/login');
68}
69
70export async function getAuthenticatedUser() {
71 const token = cookies().get('auth_token')?.value;
72 if (!token) return null;
73
74 const res = await fetch(`${STRAPI_URL}/api/users/me`, {
75 headers: { Authorization: `Bearer ${token}` },
76 cache: 'no-store',
77 });
78
79 if (!res.ok) return null;
80 return res.json();
81}Storing JWTs in HttpOnly cookies prevents JavaScript access, protecting against XSS attacks. The sameSite: 'lax' setting adds CSRF protection by preventing cookies from being sent with cross-site requests. Never store JWTs in localStorage.
The OWASP CSRF Prevention Cheat Sheet explains why this matters: localStorage is accessible to any JavaScript running on the page, making tokens vulnerable to XSS attacks and other client-side exploits. Instead, use HttpOnly cookies with both secure: true (HTTPS only in production) and sameSite: 'lax' to create defense in depth through multiple security layers.
Create frontend/middleware.ts to intercept requests before they reach protected pages:
1import { NextResponse } from 'next/server';
2import type { NextRequest } from 'next/server';
3
4export function middleware(request: NextRequest) {
5 const token = request.cookies.get('auth_token');
6
7 if (request.nextUrl.pathname.startsWith('/dashboard') && !token) {
8 return NextResponse.redirect(new URL('/login', request.url));
9 }
10
11 return NextResponse.next();
12}
13
14export const config = {
15 matcher: ['/dashboard/:path*'],
16};The Next.js middleware documentation confirms this runs at the edge before any page rendering occurs, enabling authentication checks before route rendering to protect content from unauthenticated users.
Railway provides streamlined Strapi hosting with managed PostgreSQL. According to Railway's Strapi deployment documentation, the process is mostly automated:
DATABASE_URL in your environment variables.npm install pgconfig/database.js to use environment variables for PostgreSQL configuration:1module.exports = ({ env }) => ({
2 connection: {
3 client: 'postgres',
4 connection: {
5 connectionString: env('DATABASE_URL'),
6 ssl: {
7 rejectUnauthorized: env.bool('DATABASE_SSL_SELF', false)
8 }
9 }
10 }
11});1NODE_ENV=production
2APP_KEYS=key1,key2,key3,key4
3ADMIN_JWT_SECRET=your-admin-secret
4JWT_SECRET=your-jwt-secret
5API_TOKEN_SALT=your-api-salt
6TRANSFER_TOKEN_SALT=your-transfer-token-salt
7HOST=0.0.0.0
8PORT=1337Generate unique secrets per environment using openssl rand -base64 32. Never commit .env files to version control, and rotate secrets regularly according to your security policy.
One critical detail: Railway and Render use ephemeral filesystems. Any files uploaded through Strapi's Media Library are lost on restart if stored locally. For production, configure an external storage provider like AWS S3 using the AWS S3 upload provider plugin.
According to Vercel's Next.js deployment documentation, Vercel provides zero-configuration Next.js hosting with automatic preview environments:
1STRAPI_URL=https://your-strapi-app.railway.app
2NEXT_PUBLIC_STRAPI_URL=https://your-strapi-app.railway.app
3STRAPI_API_TOKEN=your_production_tokennext.config.js to include your production Strapi domain in remotePatterns.main.Run through this checklist before calling it production-ready:
https://your-strapi-app.railway.app/api/features)If images fail to load, double-check your remotePatterns configuration in next.config.js to ensure it matches your Strapi domain exactly. If API calls return 403 errors, verify your Strapi Users & Permissions configuration by navigating to Settings → Users & Permissions → Roles and ensuring the appropriate permissions (find, findOne, create, update, delete) are enabled for each content type.
CORS configuration errors are also common when integrating Next.js with Strapi. Configure CORS in your Strapi middleware to include your Next.js frontend URL.
A few optimizations that make a real difference for SaaS landing pages:
priority prop to your hero image <Image> component. According to the Next.js Image documentation, this prevents lazy loading for above-the-fold images, which is critical for reducing Largest Contentful Paint (LCP). As noted in web.dev's image performance guide, properly optimized images can reduce LCP by 50% or more since images are often the largest contentful element.populate=* everywhere, request only the fields you need. The Strapi parameters documentation shows how field selection reduces API response sizes.'use client' for components that genuinely need interactivity, like the testimonial carousel or mobile navigation toggle.You now have a full-stack SaaS website with dynamic content management, JWT authentication, and production deployment. To keep improving your stack, consider:
The architecture you've built here (Strapi for content, Next.js for rendering, separate deployment infrastructure) scales naturally. Content editors work in Strapi's admin panel without touching code, while you maintain full control over the frontend experience and deployment pipeline.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.