Skip to content

Fix: Strapi Not Working — API Returns 403, Content Not Appearing, or Plugin Errors

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Strapi v5 issues — permissions, content types, REST and GraphQL APIs, media uploads, webhooks, plugins, and deployment configuration.

The Problem

A Strapi API request returns 403:

GET /api/articles
→ 403 Forbidden: {"data":null,"error":{"status":403,"name":"ForbiddenError","message":"Forbidden"}}

Or content you published in the admin panel doesn’t appear in the API response:

{
  "data": [],
  "meta": { "pagination": { "total": 0 } }
}

Or Strapi fails to start after installing a plugin:

Error: Cannot find module '@strapi/plugin-graphql'

Why This Happens

Strapi separates the admin panel (for content editors) from the public API (for your frontend). Creating or publishing content in the admin does not automatically expose it through the API — you must also configure permissions:

  • Permissions are role-based — the Public and Authenticated roles control what unauthenticated and authenticated users can access. By default, all permissions are off.
  • Draft vs. Published state — content must be in “Published” state to appear in the API. Drafts are only visible through the admin.
  • Plugin installation requires a clean install — adding plugins to package.json and rebuilding the admin is not always enough. Strapi caches the admin build.

The split between admin and API is the single biggest source of “where is my data?” bugs. Strapi treats the admin panel as a privileged client that bypasses the permission layer entirely. So a content entry that’s visible in the editor may be invisible to your frontend either because the role doesn’t have find enabled, because the entry is in Draft state with Draft & Publish turned on, or because the API token you’re using doesn’t include the right scope. All three look identical from the frontend (data: [] or 403), and Strapi does not log a hint about which one applies.

The plugin model adds another layer of caching. When you install @strapi/plugin-graphql or any community plugin, the admin panel needs to be rebuilt to include the plugin’s UI bundle. The server-side part of the plugin loads on the next npm run develop, but the admin’s build/ directory is only regenerated if NODE_ENV=production or you explicitly run npm run build. The plugin will appear to “work” on the backend (the GraphQL endpoint resolves) while the admin shows no settings UI, leaving you stuck without a way to grant permissions.

Fix 1: API Permissions

Go to Admin → Settings → Users & Permissions Plugin → Roles → Public (for unauthenticated access) or Authenticated.

Under the role, find your content type and enable the actions you need:

Articles:
  ✓ find      → GET /api/articles
  ✓ findOne   → GET /api/articles/:id
  ✗ create    → POST /api/articles (leave off for public)
  ✗ update    → PUT /api/articles/:id
  ✗ delete    → DELETE /api/articles/:id

Click Save. Changes take effect immediately without a restart.

You can also manage permissions programmatically in a bootstrap file:

// src/index.ts — set permissions on startup (useful for CI/CD)
export default {
  async bootstrap({ strapi }) {
    const publicRole = await strapi
      .query('plugin::users-permissions.role')
      .findOne({ where: { type: 'public' } });

    await strapi
      .query('plugin::users-permissions.permission')
      .updateMany({
        where: {
          role: publicRole.id,
          action: { $in: ['api::article.article.find', 'api::article.article.findOne'] },
        },
        data: { enabled: true },
      });
  },
};

Fix 2: Publish Content

Content must be Published (not Draft) to appear in the API.

In the admin panel, open a content entry and click the Publish button. Draft entries return no data from the API.

To verify via the REST API:

# If the array is empty, the content is likely still in Draft state
curl http://localhost:1337/api/articles

# Admins can fetch drafts by passing publicationState=preview with an API token
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
  "http://localhost:1337/api/articles?publicationState=preview"

To bulk-publish via the API (requires admin token):

// Fetch all draft articles and publish them
const drafts = await strapi.entityService.findMany('api::article.article', {
  filters: { publishedAt: { $null: true } },
});

for (const draft of drafts) {
  await strapi.entityService.update('api::article.article', draft.id, {
    data: { publishedAt: new Date() },
  });
}

How Other Tools Handle This

The headless CMS market is split between database-first systems (Strapi, Directus, Payload, KeystoneJS) and content-as-a-service platforms (Sanity, Contentful, Storyblok). The differences matter when you debug “missing data” problems.

Directus points at an existing database and infers an admin UI from the schema. Where Strapi creates a schema based on content types defined in the admin, Directus expects you to manage the schema in SQL or migrations and then exposes whatever it finds. Directus’ permission model is more granular (per-collection, per-field, with custom roles), but the lack of a code-first content type model means version control of the content model lives in the database, not in files. Strapi v5 stores content types in src/api/*/content-types/, which means Git tracks them naturally.

Payload CMS is code-first like Strapi but uses TypeScript collections instead of a JSON schema generated from the admin. Permissions in Payload are JavaScript functions evaluated on every request, which is more powerful than Strapi’s role-action matrix but harder to reason about. Payload also bundles its admin into a Next.js app, so deployment is one process; Strapi runs a separate admin server in production. See Fix: Payload CMS Not Working for symptoms that look similar but have different root causes.

KeystoneJS uses GraphQL natively and treats every field as a typed graph node. It’s the most “framework-like” of the self-hosted CMSes — closer to a SaaS BFF than a traditional CMS. Strapi added GraphQL as a plugin; Keystone makes it the only API. If you want REST as a primary interface, Strapi is friendlier.

Sanity is the SaaS opposite. Content lives in Sanity’s hosted dataset, the API is always available, permissions are configured per dataset in the Sanity dashboard, and querying uses GROQ rather than REST or GraphQL. There’s no “publish to make the API see it” step because everything is mutation-driven and CDN-cached. The flip side is that you’re paying per request and storage, with vendor lock-in. See Fix: Sanity Not Working for SaaS-specific issues like CORS and dataset mismatches.

Contentful is similar to Sanity in operating model but uses a structured Content Model and a more rigid type system. It’s the slowest to iterate on schema changes but the easiest to integrate with marketing/editorial workflows.

A practical rule: pick Strapi or Directus when you want SQL access and self-hosting, pick Payload or Keystone when you want code-first schemas and tighter Node integration, and pick Sanity or Contentful when you want to skip the ops entirely. The error you’re seeing — especially 403 Forbidden — almost always traces back to which CMS owns the permission layer your token is checked against. See Fix: Directus Not Working for the analog of this article in the SQL-native world.

Fix 3: REST API Usage

// Fetch a list of articles (with pagination)
const res = await fetch('http://localhost:1337/api/articles?populate=*');
const { data, meta } = await res.json();

// data is an array of { id, attributes: { title, content, ... } }
// In Strapi v5, data is flat (no .attributes wrapper):
// { id, title, content, createdAt, ... }

// Pagination
const res = await fetch(
  'http://localhost:1337/api/articles?pagination[page]=1&pagination[pageSize]=10'
);

// Filtering
const res = await fetch(
  'http://localhost:1337/api/articles?filters[category][name][$eq]=Tech&sort=createdAt:desc'
);

// Populate relations
const res = await fetch(
  'http://localhost:1337/api/articles?populate[author][fields][0]=name&populate[category][fields][0]=name'
);

// With authentication
const res = await fetch('http://localhost:1337/api/articles', {
  headers: {
    Authorization: `Bearer ${userJwt}`, // JWT from login
  },
});
// Login to get a JWT
const loginRes = await fetch('http://localhost:1337/api/auth/local', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    identifier: '[email protected]',
    password: 'password',
  }),
});
const { jwt, user } = await loginRes.json();

// Use jwt in subsequent requests
// Create a record (requires create permission)
const res = await fetch('http://localhost:1337/api/articles', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${jwt}`,
  },
  body: JSON.stringify({
    data: {
      title: 'My Article',
      content: 'Article content here',
      publishedAt: new Date().toISOString(), // Publish immediately
    },
  }),
});

Fix 4: GraphQL API

# Install the GraphQL plugin
npm install @strapi/plugin-graphql

# Rebuild admin and restart
npm run build && npm run develop
// config/plugins.ts
export default {
  graphql: {
    enabled: true,
    config: {
      endpoint: '/graphql',
      shadowCRUD: true,
      playgroundAlways: false, // Set true in dev for the playground
      depthLimit: 7,
      amountLimit: 100,
    },
  },
};
# Query articles via GraphQL
query {
  articles(
    pagination: { page: 1, pageSize: 10 }
    filters: { category: { name: { eq: "Tech" } } }
    sort: ["createdAt:desc"]
  ) {
    data {
      id
      attributes {
        title
        content
        createdAt
        author {
          data {
            attributes {
              name
            }
          }
        }
      }
    }
    meta {
      pagination {
        total
        pageCount
      }
    }
  }
}

Common Mistake: GraphQL permissions are separate from REST permissions. After enabling the GraphQL plugin, go back to Settings → Roles and enable GraphQL-specific actions (they appear alongside the REST ones).

Fix 5: Media Uploads

// Upload a file
const formData = new FormData();
formData.append('files', fileInput.files[0]);
formData.append('ref', 'api::article.article');  // Content type
formData.append('refId', articleId);              // Record ID
formData.append('field', 'cover');                // Field name

const uploadRes = await fetch('http://localhost:1337/api/upload', {
  method: 'POST',
  headers: { Authorization: `Bearer ${jwt}` },
  body: formData,
});

const [uploadedFile] = await uploadRes.json();
console.log(uploadedFile.url); // /uploads/filename_abc123.jpg
// Configure S3 for production storage
// npm install @strapi/provider-upload-aws-s3
// config/plugins.ts
export default {
  upload: {
    config: {
      provider: 'aws-s3',
      providerOptions: {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
        region: process.env.AWS_REGION,
        params: {
          Bucket: process.env.AWS_BUCKET,
        },
      },
      actionOptions: {
        upload: {},
        uploadStream: {},
        delete: {},
      },
    },
  },
};

Fix 6: Custom API Routes and Controllers

// src/api/article/routes/custom-article.ts
export default {
  routes: [
    {
      method: 'GET',
      path: '/articles/featured',
      handler: 'article.getFeatured',
      config: {
        policies: [],
        middlewares: [],
      },
    },
  ],
};

// src/api/article/controllers/article.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreController(
  'api::article.article',
  ({ strapi }) => ({
    async getFeatured(ctx) {
      const articles = await strapi.entityService.findMany(
        'api::article.article',
        {
          filters: { featured: true },
          populate: ['author', 'cover'],
          sort: { createdAt: 'desc' },
          limit: 5,
        }
      );
      ctx.body = { data: articles };
    },
  })
);
// src/api/article/middlewares/article.ts — custom middleware
export default () => async (ctx, next) => {
  console.log('Request to articles:', ctx.request.method, ctx.request.url);
  await next();
  console.log('Response status:', ctx.response.status);
};

Fix 7: Environment Configuration and Deployment

# .env — required variables
HOST=0.0.0.0
PORT=1337
APP_KEYS=your_app_key_1,your_app_key_2
API_TOKEN_SALT=your_api_token_salt
ADMIN_JWT_SECRET=your_admin_jwt_secret
TRANSFER_TOKEN_SALT=your_transfer_token_salt
JWT_SECRET=your_jwt_secret

DATABASE_CLIENT=postgres   # or mysql, sqlite
DATABASE_URL=postgresql://user:password@localhost:5432/strapi
// config/database.ts
export default ({ env }) => ({
  connection: {
    client: env('DATABASE_CLIENT', 'sqlite'),
    connection:
      env('DATABASE_CLIENT') === 'sqlite'
        ? { filename: env('DATABASE_FILENAME', '.tmp/data.db') }
        : {
            host: env('DATABASE_HOST', '127.0.0.1'),
            port: env.int('DATABASE_PORT', 5432),
            database: env('DATABASE_NAME', 'strapi'),
            user: env('DATABASE_USERNAME', 'strapi'),
            password: env('DATABASE_PASSWORD'),
            ssl: env.bool('DATABASE_SSL', false),
          },
    useNullAsDefault: true,
    debug: false,
  },
});
# Production build
NODE_ENV=production npm run build
NODE_ENV=production npm run start

# Docker
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 1337
CMD ["npm", "start"]

Still Not Working?

Empty data array despite published content — permissions aren’t set. Go to Settings → Roles → Public → enable find and findOne for your content type.

Admin panel not loading after plugin install — delete .cache/ and build/ directories, then run npm run build again. Strapi’s admin build is cached and doesn’t always detect new plugins.

Database migration errors — after changing a content type schema, Strapi runs migrations automatically in development. In production, migrations don’t auto-run. Use npm run strapi migration:run or check src/migrations/ for manual migration files.

CORS errors from the frontend — configure allowed origins in config/middlewares.ts:

export default [
  'strapi::errors',
  {
    name: 'strapi::security',
    config: {
      contentSecurityPolicy: {
        useDefaults: true,
        directives: { 'img-src': ["'self'", 'data:', 'blob:', '*.s3.amazonaws.com'] },
      },
    },
  },
  {
    name: 'strapi::cors',
    config: {
      origin: ['https://your-frontend.com', 'http://localhost:3000'],
      methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
      headers: ['Content-Type', 'Authorization'],
    },
  },
  // ... rest of middlewares
];

Webhooks fire but the receiver gets a malformed payload — Strapi v4 and v5 send different JSON shapes. v4 uses entry.attributes.*; v5 uses flat entry.*. Update your webhook receiver to handle both shapes, or pin Strapi to a major version and align the receiver accordingly.

Admin login fails with Invalid credentials on first install — the bootstrap admin user is created the first time the panel loads. If you ran wasp build (or any production build) before opening the panel locally, the admin form may submit against a route that hasn’t initialized the database table yet. Delete the data.db (SQLite) or run a fresh migration, then load /admin and create the first user before any other startup.

File uploads work locally but break on S3 — Strapi defaults to local disk storage. After adding @strapi/provider-upload-aws-s3, you must also configure config/plugins.js and config/middlewares.js to allow your S3 bucket’s domain in the CSP img-src directive. A missing CSP entry silently strips the image from the rendered admin preview, making it look like the upload failed.

For related CMS and API issues, see Fix: Next.js API Route Not Working and Fix: Payload CMS Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles