✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

Ecosystem18 min read

How to Build a Customer and Partner Portal with Strapi and Next.js

June 19, 2026Updated on June 23, 2026
Build a Customer & Partner Portal_ Strapi + Next.js

Businesses routinely need to show different content to different audiences. Customers want their orders and support tickets. Partners want shared resources and deal pipelines. Unauthenticated visitors should see none of it. Most teams solve this by spinning up separate applications or layering custom backend logic that gets harder to maintain over time.

This tutorial takes a different approach: a single portal application where Strapi 5 works as the headless CMS for content modeling, permissions, and API delivery, while Next.js 16 handles authentication, routing, and role-gated rendering. Customers see their tickets. Partners see their deals. One codebase serves both audiences with role-based content separation enforced at every layer.

In Brief:

  • Set up Strapi 5 with custom Collection Types for portal-specific content (tickets, resources, deals)
  • Configure the Users and Permissions plugin so portal users only reach the content their access model allows
  • Build a Next.js 16 App Router frontend with JSON Web Token (JWT) authentication and role-gated pages
  • Restrict API responses with a custom policy so users only access their own data

Prerequisites

You'll need Node.js 20.9 or later since Next.js 16 requires it as the minimum LTS version (and Strapi 5 also targets Active or Maintenance LTS releases), plus working installs of Strapi 5 and Next.js 16 before you start. Confirm you have the following ready:

  • Node.js v20.9 or later
  • Strapi 5
  • Next.js 16 (App Router)
  • Basic familiarity with REST APIs and React

Set Up the Strapi Backend

The Strapi backend provides the content models, role configuration, and API layer that the portal depends on. Scaffold a fresh Strapi 5 project with the following command:

npx create-strapi@latest portal-backend

Follow the prompts to choose your database (SQLite works fine for development) and wait for the installation to finish. Once complete, cd portal-backend and run npm run develop. Strapi starts at http://localhost:1337. Create your first admin account through the registration screen.

With the Admin Panel running, you're ready to define the data structures that power your portal in a headless CMS setup.

Create the Portal Content Types

Content Types define the shape of your data in Strapi 5. Each Collection Type you create generates its own REST API endpoints, admin panel entry screens, and permission rules automatically.

For this portal, you need three Collection Types: one for customer support tickets, one for shared resources accessible to both audiences, and one for the partner deal pipeline. Open the Content-Type Builder from the left navigation and create the following:

Ticket

The Ticket Collection Type stores customer support requests. Each ticket links to the user who submitted it through a relation field, and tracks resolution progress with a status enumeration. Add the following fields:

  • subject: Text
  • description: Rich Text (Markdown)
  • status: Enumeration with values open, in-progress, resolved
  • customer: Relation (many-to-one) → User (plugin::users-permissions.user)

Resource

Resources are shared documents and files available to customers, partners, or both. A visibility field controls which portal audience can access each entry. Add the following fields:

  • title: Text
  • file: Media (single file)
  • category: Enumeration with values docs, marketing, technical
  • visibility: Enumeration with values customer, partner, all

Deal

The Deal Collection Type tracks the partner sales pipeline. Each deal links to the partner who owns it and moves through stages from prospecting to close. Add the following fields:

  • name: Text
  • value: Number (decimal)
  • stage: Enumeration with values prospecting, negotiation, closed-won, closed-lost
  • partner: Relation (many-to-one) → User (plugin::users-permissions.user)

Each Relation field targets plugin::users-permissions.user. In the schema file, that looks like this:

"customer": {
  "type": "relation",
  "relation": "manyToOne",
  "target": "plugin::users-permissions.user"
}

Click Save after creating each type. in development mode, Strapi hot-reloads automatically and the first restart after a schema change typically takes a few seconds longer than usual.

Strapi 5 uses documentId (a stable string identifier) instead of the numeric id from v4 for content entries in API operations. You'll reference documentId throughout your content queries. For user relations, keep your checks aligned with the user object returned by authentication.

Configure Roles and Permissions

Strapi has two distinct role systems, and confusing them is a common mistake. Admin Role-Based Access Control (RBAC) governs admin panel users (editors, authors). The Users and Permissions plugin governs end users of your API, which is what you need here.

The plugin ships with two default end-user roles: Authenticated and Public. In this portal, the important part is mapping access rules so customer-facing users can reach Tickets and Resources, partner-facing users can reach Deals and Resources, and public users reach none of it.

Configure the permissions in Settings → Users and Permissions plugin → Roles so your portal users only get access to the routes they need.

For customer-facing access:

  1. Open the role that should allow signed-in customer access
  2. Expand the permission categories for your content types
  3. Grant find and findOne on Ticket and Resource
  4. Deny access to Deal
  5. Save

For partner-facing access:

  1. Open the role that should allow signed-in partner access
  2. Grant find and findOne on Resource and Deal
  3. Deny access to Ticket
  4. Save

The permissions grid shows bound API routes on the right panel as you toggle checkboxes. This visual confirmation helps verify that each role can only reach the endpoints you intend.

Fine-grained API-level permissions matter here because they form your first line of defense. If a user should not access /api/tickets, the request gets rejected before any controller logic runs.

Restrict Data Access with a Custom Policy

Permissions control which endpoints a role can call, but they don't filter whose data comes back. A customer-facing user calling GET /api/tickets can still receive every ticket in the system unless you scope the query. Policies help with that.

Write an Owner-Only Policy

Policies are functions that run before the controller on each request. They return true to allow the request or false to block it. Create a global policy at ./src/policies/is-owner.js:

// ./src/policies/is-owner.js
module.exports = (policyContext, config, { strapi }) => {
  const user = policyContext.state.user;

  if (!user) {
    return false;
  }

  // Inject a filter so the controller only returns entries belonging to this user
  const relationField = config.relationField || 'customer';
  policyContext.query = {
    ...policyContext.query,
    filters: {
      ...policyContext.query?.filters,
      [relationField]: {
        id: { $eq: user.id },
      },
    },
  };

  return true;
};

The function signature is (policyContext, config, { strapi }). policyContext wraps the controller context and works for both REST and GraphQL. The policy's config parameter receives the route's options object exactly as defined in the route configuration when the policy is attached. policyContext.state.user holds the authenticated user object.

Be aware that returning nothing (undefined) from a policy does not block the request. Always return an explicit false to deny access.

Apply the Policy to Routes

Override the default core router for each Collection Type using createCoreRouter with a config object that attaches your policy:

// ./src/api/ticket/routes/ticket.js
const { createCoreRouter } = require('@strapi/strapi').factories;

module.exports = createCoreRouter('api::ticket.ticket', {
  config: {
    find: {
      policies: [
        { name: 'global::is-owner', config: { relationField: 'customer' } },
      ],
    },
  },
});

Do the same for deals, pointing to the partner relation:

// ./src/api/deal/routes/deal.js
const { createCoreRouter } = require('@strapi/strapi').factories;

module.exports = createCoreRouter('api::deal.deal', {
  config: {
    find: {
      policies: [
        { name: 'global::is-owner', config: { relationField: 'partner' } },
      ],
    },
  },
});

Global policies use the global:: prefix. API-scoped policies use api::api-name.policy-name. The config object you pass at the route level maps directly to the config parameter in the policy function, so config.relationField resolves to 'customer' or 'partner' depending on the route.

If you also need strict owner checks on findOne, use an explicit ownership check in custom controller logic instead of relying on an injected filter alone. An alternative approach is to write a custom controller or Document Service middleware that filters results via strapi.documents('api::ticket.ticket').findMany({ filters: ... }). The trade-off is clear: policies can shape list queries early, while controller-level logic gives you a clearer place to verify ownership before returning a single entry.

Build the Next.js Frontend

The Next.js frontend handles authentication, role-based routing, and data rendering for each portal audience. Scaffold a Next.js 16 project with the following command:

npx create-next-app@latest portal-frontend

The Next.js 16 CLI defaults to TypeScript, ESLint, Tailwind CSS, the App Router, and Turbopack, so you can accept the defaults at each prompt. The project structure you'll work with:

  • app/ for routes and layouts
  • lib/ for API helpers and the data access layer
  • context/ for the auth provider
  • app/api/auth/ for Route Handlers that bridge HTTP-only cookies

Handle Authentication with JWT

Strapi's auth endpoints expose /api/auth/local for login and /api/auth/local/register for registration. The login endpoint accepts an identifier (email or username) and password, then returns a JWT and user object.

The JWT should live in an HTTP-only cookie, not localStorage. Client-side JavaScript cannot read HTTP-only cookies, which protects the token from Cross-Site Scripting (XSS) attacks. Since Next.js client components cannot set HTTP-only cookies directly, you need a Route Handler as an intermediary:

// app/api/auth/login/route.ts
import { cookies } from 'next/headers'

export async function POST(request: Request) {
  const { email, password } = await request.json()

  const res = await fetch(`${process.env.STRAPI_URL}/api/auth/local`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ identifier: email, password }),
  })

  const data = await res.json()

  if (!res.ok) {
    return new Response(
      JSON.stringify({ error: 'Invalid credentials' }),
      { status: 401 }
    )
  }

  const cookieStore = await cookies()
  cookieStore.set('auth_token', data.jwt, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7,
    path: '/',
  })

  return new Response(JSON.stringify({ user: data.user }), { status: 200 })
}

Note that the auth response format doesn't follow the standard data wrapper. It returns { jwt, user } directly.

Build a lib/strapi.ts helper that attaches the Authorization header to every server-side fetch:

// lib/strapi.ts
import 'server-only'
import { cookies } from 'next/headers'

const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337'

async function getToken(): Promise<string | null> {
  const cookieStore = await cookies()
  return cookieStore.get('auth_token')?.value ?? null
}

export async function strapiAuthFetch(
  endpoint: string,
  options: RequestInit = {}
): Promise<Response> {
  const token = await getToken()

  return fetch(`${STRAPI_URL}/api${endpoint}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...options.headers,
    },
    cache: 'no-store',
  })
}

The cache: 'no-store' option matters for portal data. Every request is user-specific, so caching shared responses would risk leaking data between sessions.

Registration hits /api/auth/local/register. The role assigned to new users depends on the default role setting at Settings → Users and Permissions plugin → Advanced settings.

Programmatic role assignment at registration time is typically customized by extending the Users & Permissions plugin's registration logic via the plugin extension system under src/extensions/users-permissions/. For most portal setups, you'll create users through the admin panel and assign access deliberately.

Create a Reusable Auth Context

Wrap the app in a context provider that exposes user, role, login(), logout(), and isLoading. Since it uses useState and browser APIs, mark it as 'use client':

// context/AuthContext.tsx
'use client'

import React, { createContext, useContext, useState, useEffect } from 'react'

type User = { id: number; documentId: string; email: string; role?: { name: string } }

type AuthContextType = {
  user: User | null
  role: string | null
  isLoading: boolean
  login: (email: string, password: string) => Promise<void>
  logout: () => Promise<void>
}

const AuthContext = createContext<AuthContextType | null>(null)

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [isLoading, setIsLoading] = useState(true)

  useEffect(() => {
    async function loadUser() {
      try {
        const res = await fetch('/api/auth/me')
        if (res.ok) {
          const data = await res.json()
          setUser(data.user)
        }
      } catch {
        setUser(null)
      } finally {
        setIsLoading(false)
      }
    }
    loadUser()
  }, [])

  const login = async (email: string, password: string) => {
    setIsLoading(true)
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })
    if (res.ok) {
      const data = await res.json()
      setUser(data.user)
    }
    setIsLoading(false)
  }

  const logout = async () => {
    await fetch('/api/auth/logout', { method: 'POST' })
    setUser(null)
  }

return (
    <AuthContext.Provider
      value={{ user, role: user?.role?.name ?? null, isLoading, login, logout }}
    >
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const ctx = useContext(AuthContext)
  if (!ctx) throw new Error('useAuth must be used within AuthProvider')
  return ctx
}

Place the provider in your root layout. The layout file itself can stay a Server Component, while the imported AuthProvider is a Client Component boundary that is prerendered on the server and hydrated to run in the browser:

// app/layout.tsx
import { AuthProvider } from '@/context/AuthContext'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <AuthProvider>{children}</AuthProvider>
      </body>
    </html>
  )
}

React context is not supported in Server Components, so Server Components can read data directly from cookies and pass it down to Client Components when needed. The context provider handles client-side UI state, while Server Components use cookies() from next/headers for authorization.

Build Role-Gated Portal Pages

Each portal view fetches from a different Strapi endpoint and renders content based on the logged-in user's role. This is where the roles, policies, and data types converge.

Customer Dashboard

Create a Server Component at app/portal/customer/page.tsx that fetches the authenticated user's tickets:

// app/portal/customer/page.tsx
import { redirect } from 'next/navigation'
import { strapiAuthFetch } from '@/lib/strapi'
import { cookies } from 'next/headers'

async function getUser() {
  const res = await strapiAuthFetch('/users/me?populate=role')
  if (!res.ok) return null
  return res.json()
}

export default async function CustomerDashboard() {
  const user = await getUser()
  if (!user) redirect('/login')

  const ticketRes = await strapiAuthFetch(
    `/tickets?filters[customer][id][$eq]=${user.id}&populate=*`
  )
  const { data: tickets } = await ticketRes.json()

  return (
    <div>
      <h1>My Tickets</h1>
      <table>
        <thead>
          <tr>
            <th>Subject</th>
            <th>Status</th>
            <th>Created</th>
          </tr>
        </thead>
        <tbody>
          {tickets.map((ticket: any) => (
            <tr key={ticket.documentId}>
              <td>{ticket.subject}</td>
              <td>
                <span className={`badge badge-${ticket.status}`}>
                  {ticket.status}
                </span>
              </td>
              <td>{new Date(ticket.createdAt).toLocaleDateString()}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

Strapi 5's flat response format means you access fields directly at ticket.subject, not the old v4 pattern of ticket.attributes.subject. The response structure is:

{
  "data": [
    {
      "id": 1,
      "documentId": "hgv1vny5cebq2l3czil1rpb3",
      "subject": "Billing question",
      "status": "open",
      "createdAt": "2025-01-15T09:00:00.000Z"
    }
  ],
  "meta": { "pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 1 } }
}

Partner Dashboard

The partner page at app/portal/partner/page.tsx fetches deals and shared resources:

// app/portal/partner/page.tsx
import { redirect } from 'next/navigation'
import { strapiAuthFetch } from '@/lib/strapi'

async function getUser() {
  const res = await strapiAuthFetch('/users/me?populate=role')
  if (!res.ok) return null
  return res.json()
}

export default async function PartnerDashboard() {
  const user = await getUser()
  if (!user) redirect('/login')

  const dealRes = await strapiAuthFetch(
    `/deals?filters[partner][id][$eq]=${user.id}&populate=*`
  )
  const { data: deals } = await dealRes.json()

  const resourceRes = await strapiAuthFetch(
    `/resources?filters[visibility][$in][0]=partner&filters[visibility][$in][1]=all`
  )
  const { data: resources } = await resourceRes.json()

  return (
    <div>
      <h2>Deal Pipeline</h2>
      <div className="pipeline">
        {deals.map((deal: any) => (
          <div key={deal.documentId} className={`card stage-${deal.stage}`}>
            <h3>{deal.name}</h3>
            <p>${deal.value.toLocaleString()}</p>
            <span>{deal.stage}</span>
          </div>
        ))}
      </div>

      <h2>Resources</h2>
      <ul>
        {resources.map((resource: any) => (
          <li key={resource.documentId}>
            <a href={resource.file?.url} download>
              {resource.title}
            </a>
            <span>{resource.category}</span>
          </li>
        ))}
      </ul>
    </div>
  )
}

The $in operator uses bracket notation with indexed values: filters[visibility][$in][0]=partner&filters[visibility][$in][1]=all. This returns resources visible to partners or to everyone.

By default, the REST API does not populate any relations or media fields. Add populate=* to get one level of related data, or use selective population like populate[file][fields][0]=url to keep responses lean.

Protect Routes with Proxy

Create a proxy.ts at the project root. In Next.js 16, the middleware file convention was deprecated and renamed to proxy, and the runtime now defaults to Node.js (the edge runtime is not supported in proxy files). If you're upgrading an existing project, run npx @next/codemod@canary middleware-to-proxy . to migrate automatically.

This Next.js proxy intercepts requests to /portal/* and handles both authentication and role-based routing:

// proxy.ts
import { NextRequest, NextResponse } from 'next/server'

export async function proxy(req: NextRequest) {
  const path = req.nextUrl.pathname
  const token = req.cookies.get('auth_token')?.value

  // No token: redirect to login
  if (!token) {
    return NextResponse.redirect(new URL('/login', req.nextUrl))
  }

  // Fetch user role from Strapi
  const userRes = await fetch(
    `${process.env.STRAPI_URL}/api/users/me?populate=role`,
    {
      headers: { Authorization: `Bearer ${token}` },
    }
  )

  if (!userRes.ok) {
    return NextResponse.redirect(new URL('/login', req.nextUrl))
  }

  const user = await userRes.json()
  const role = user.role?.name?.toLowerCase()

  // Role mismatch: redirect to correct dashboard
  if (path.startsWith('/portal/customer') && role !== 'customer') {
    return NextResponse.redirect(new URL(`/portal/${role}`, req.nextUrl))
  }
  if (path.startsWith('/portal/partner') && role !== 'partner') {
    return NextResponse.redirect(new URL(`/portal/${role}`, req.nextUrl))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/portal/:path*'],
}

The :path* pattern matches zero or more path segments under /portal/. NextResponse.next() lets the request proceed when everything checks out.

For performance, the official Next.js auth guide recommends using the proxy only for optimistic checks, such as reading cookies or basic JWT validation, and performing authoritative session verification as close to the data source as possible. For a production deployment, consider verifying session state in your Server Components and Route Handlers instead of calling Strapi from the proxy on every request.

Add Shared Portal Features

Some portal functionality applies to both customers and partners: profile management, a shared resource library, and event-driven notifications. These features use the same role-aware patterns established above.

Profile Management

Both Customers and Partners need a profile page. Create app/portal/profile/page.tsx that reads the current user from GET /api/users/me?populate=role and updates details via PUT /api/users/:id:

// app/portal/profile/page.tsx
import { strapiAuthFetch } from '@/lib/strapi'
import { redirect } from 'next/navigation'
import ProfileForm from './ProfileForm'

export default async function ProfilePage() {
  const res = await strapiAuthFetch('/users/me?populate=role')
  if (!res.ok) redirect('/login')

  const user = await res.json()

  return (
    <div>
      <h1>My Profile</h1>
      <p>Role: {user.role?.name}</p>
      <ProfileForm user={user} />
    </div>
  )
}

The ProfileForm client component calls a Route Handler that proxies the PUT /api/users/:id request, keeping the JWT in the HTTP-only cookie and out of client-side code.

Resource Library with Role-Based Filtering

Rather than building separate resource pages for each role, a single /portal/resources page serves both customers and partners. The page reads the authenticated user's role, then constructs a query filter that returns only resources matching that role's visibility level plus any resources marked as visible to all users:

// app/portal/resources/page.tsx
import { strapiAuthFetch } from '@/lib/strapi'
import { redirect } from 'next/navigation'

export default async function ResourcesPage() {
  const userRes = await strapiAuthFetch('/users/me?populate=role')
  if (!userRes.ok) redirect('/login')

  const user = await userRes.json()
  const role = user.role?.name?.toLowerCase()

  // Filter resources by the user's role
  const resourceRes = await strapiAuthFetch(
    `/resources?filters[visibility][$in][0]=${role}&filters[visibility][$in][1]=all&populate=*`
  )
  const { data: resources } = await resourceRes.json()

  return (
    <div>
      <h1>Resource Library</h1>
      {resources.map((resource: any) => (
        <div key={resource.documentId}>
          <h3>{resource.title}</h3>
          <span>{resource.category}</span>
          {resource.file && (
            <a href={resource.file.url} download>Download</a>
          )}
        </div>
      ))}
    </div>
  )
}

Customers see resources where visibility is customer or all. Partners see partner or all. The role value passes dynamically into the filter, so one page serves both audiences.

Notification System with Webhooks

Configure a Strapi webhook at Settings → Webhooks that fires on entry.create. Point it at a Next.js API route:

// app/api/webhooks/strapi/route.ts
export async function POST(request: Request) {
  const payload = await request.json()

  const { event, model, entry } = payload

  if (event === 'entry.create' && model === 'ticket') {
    // Trigger email or push notification for new ticket
    console.log(`New ticket created: ${entry.subject}`)
  }

  if (event === 'entry.create' && model === 'deal') {
    // Notify partner of new deal registration
    console.log(`New deal registered: ${entry.name}`)
  }

  return new Response(null, { status: 200 })
}

You can add a shared secret via webhook headers in ./config/server.js and validate it in the route handler. One limitation to know: webhooks do not fire for the User content-type. If you need notifications on new user registrations, use the users-permissions plugin lifecycle hooks or related customization under src/extensions/users-permissions/... instead.

Deploy the Portal Stack

Deploying a Strapi + Next.js portal means running two services: the Strapi API backend and the Next.js frontend. Deploy Strapi first so the API is available when Next.js builds.

Deploy Strapi to Production

Strapi Cloud is the fastest path. It connects to your GitHub or GitLab repository and can deploy on push. A managed database is included, with infrastructure handled by Strapi for typical projects.

Railway and Render are solid alternatives. Railway offers usage-based pricing that scales to zero, with integrated PostgreSQL in the same environment. Render provides zero-downtime deploys and supports horizontal autoscaling.

Regardless of host, set these environment variables:

  • DATABASE_URL (or individual DATABASE_HOST, DATABASE_PORT, etc.)
  • JWT_SECRET (for Users and Permissions Content API tokens)
  • API_TOKEN_SALT
  • APP_KEYS
  • ADMIN_JWT_SECRET

Configure the strapi::cors middleware in ./config/middlewares.js to allow requests from your Next.js frontend origin:

// ./config/middlewares.js
module.exports = [
  'strapi::logger',
  'strapi::errors',
  'strapi::security',
  {
    name: 'strapi::cors',
    config: {
      origin: ['https://your-portal.vercel.app', 'http://localhost:3000'],
      methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
      headers: ['Content-Type', 'Authorization', 'Origin', 'Accept'],
    },
  },
  'strapi::poweredBy',
  'strapi::query',
  'strapi::body',
  'strapi::session',
  'strapi::favicon',
  'strapi::public',
];

Deploy Next.js to Vercel

Set the STRAPI_URL environment variable in your Vercel project settings. Do not use the NEXT_PUBLIC_ prefix for this variable since the Strapi URL should only be accessed server-side through your Route Handlers and Server Components.

Since all portal pages use cache: 'no-store', they fetch at request time, not build time. This avoids the common failure scenario where next build crashes because Strapi is not reachable during the build process. Deploy Strapi first, confirm it's running, then trigger the Next.js build.

How Strapi Powers This

This tutorial built a single portal application that serves customers and partners from the same codebase, with role-based content separation and owner-scoped data access enforced at every layer. Strapi 5 enabled this approach through:

  • The Content-Type Builder modeled Tickets, Deals, and Resources without writing schema files, with relation fields linking each entry to its owning user.
  • The Users and Permissions plugin mapped API-level access per role so unauthorized requests get rejected before controller logic runs.
  • Global policies injected ownership filters into queries, scoping list responses to the authenticated user's data.
  • The flat response format with documentId simplified frontend data access, removing the nested .attributes wrapper from v4.
  • Webhook support enabled event-driven notifications when new tickets or deals are created.

Get started with Strapi 5 for free and build your first portal Content-Type today.

Paul BratslavskyDeveloper Advocate

Related Posts

Epic Next.js and Strapi Tutorial
14 min read

Epic Next.js 15 Tutorial Part 2: Building Out The Home Page

This post is part 2 of many in our Epic Next.js Series. You can find the outline for upcoming posts here. ...

·August 17, 2025
Build a Customer Review APP with Strapi and Solid.js
Beginner·11 min read

Build a Customer Review App with Strapi and Solid.js

This article refers to Strapi v4. For Strapi v5, use the updated resources below: Start here Strapi 5 docs (source of...

·April 29, 2024
How Kenner Soft Built a Military Informational Portal with Strapi
Use Cases·3 min read

How Kenner Soft Built a Military Informational Portal with Strapi

Helping companies improve their digital process on Shopware, Oxid, and JTL. Implementation of product configuration and PIM solution. ERP integration and automatization.

·July 28, 2025