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

BlogIntermediate34 min read

Powering a Job Board application using Strapi and SwiftUI

August 7, 2023Updated on July 9, 2026

In this tutorial we will build a job board application iOS app using SwiftUI and Strapi. We will learn how to extend Strapi collections, add custom routes and HTTP request handlers(controllers). We will use custom controller functions to handle file uploads associated with a specific record. We will add web sockets functionality to enable an applicant and a job advertiser to send each other messages in real time. We will use the user-permissions plugin to authenticate each socket connection.

Outline

  • Introduction
  • Prerequisites
  • Pairing SwiftUI with Strapi
  • Setting up a Strapi project
  • Creating Collections
  • Extending Collection Routes and Controllers
  • Socket Setup and Authentication
  • Initlizing a SwiftUI Project
  • Socket IO Client installation
  • Setting up network Services and Models
  • SwiftUI Authentication Flow
  • Conclusion.

Introduction

Job boards made their debut in the internet towards the end of the 20th Century during the early stages of the Internet. The earliest job board was developed by a company named NetStart Inc in 1995. The platform allowed employers to post job openings and job seekers to search and apply for them. This was a pretty impressive website for its time and maybe considered as the pioneer of online Job boards. NetStart Inc has morphed through the years and is currently known as CareerBuilder.

Job boards have grown from plain old create, read, update and delete(CRUD) websites to websites with features such as job alerts using email and push notifications, application tracking and resume analysis. Furthermore there have emerged job boards that cater for specific industries and professions. Some can even be considered as career networking platforms where users can vouch each others strengths and skills.

Currently, there are many job boards available on the internet. Most organisations and large cooperations have a job board on their own websites where they list open positions in their workforce. In this tutorial, we will be building a job board application using SwiftUI. The application will be an iOS application that is highly dependent on a Strapi Server instance.

Prerequisites

An Integrated Development Environment, I use VS Code but you are free to use others.

  • Prior Strapi knowledge is helpful but not required - Learn the basics of Strapi v4.
  • Basic JSON Web Token Authentication Knowledge.

Pairing SwiftUI with Strapi

SwiftUI is a user interface (UI) toolkit that was developed by Apple in 2019. It is based upon Apple’s Swift Programming language. SwiftUI eases the process of creating UIs by providing a declarative programming model that enables developers to outline the structure and behaviour of UI components in a concise and easy to read syntax. Furthermore SwiftUI supports adaptive UI layouts which enables developers to cater for different display sizes, resolutions and orientations. This helps in streamlining the software development process for visually appealing user interfaces on Apple platforms.

By pairing SwiftUI with Strapi we are able to create applications that communicate efficiently because SwiftUI has reactive UI capabilities meaning that any data change from our Strapi backend will be rendered almost immediately in a resource efficient way. In addition to this, Strapi is relatively easy to use and could be used to create consumable Application Programming Interfaces (APIs) within minutes. When combined with SwiftUI’s prebuilt UI components, data can be quickly retrieved and displayed across all devices in Apple’s ecosystem. This reduces the development time of software project by reducing the amount of code needed to setup a fully functioning system.

Both Strapi and SwiftUI are highly customizable making them really powerful solution development tools. Strapi can be scaled to accommodate high user traffic.

Setting up Strapi

To get started, we will start by setting up our Strapi server instance. Strapi is highly dependent on Node. Make sure you have it installed on your development machine. We will use the npx command which is a node package runner that will execute a script and scaffold a new strapi project inside of a project folder in the current working directory.

Open your terminal or command line prompt (cmd/terminal) and run the following command to create a scaffold of the Strapi server.

    npx create-strapi-app@latest backend --quickstart

The command creates a bare-bones Strapi system, fetchs and installs necessary dependencies from the package.json file then initializes an SQLite Database. Other database management systems can be used using the following guide. For SQLite to work, you may need to install it using the following link. Once every dependency has been installed, your default browser will open and render the admin registration page for Strapi. Fill in all the required fields to create your administrator account then you’ll be welcomed with the page below.

strapi-admin.png

Creating Collections

We will use Strapi’s content-type builder plugin to define a schema which will describe the structure and rules for organizing and validating data in the database. It will guide the database on how we want our records stored. We will begin by creating a company schema, job schema and application schema. The application schema and company schema will have a relation with the user schema which was initiated after we scaffolded the strapi server. The user schema contains the necessary fields needed to identify a user of the system.

Company Collection

This collection will be used for a company’s details.

  1. Click on Content-type Builder under plugins in the side navigation bar.
  2. Click on Create new collection type.
  3. Type Company for the Display name and click Continue.
  4. Click the Text field button.
  5. Type n``ame in the Name field.
  6. Repeat the steps above and add text fields for address, email, phone, bio and category within the collection.
  7. Create a media field and set it to a single media type field. Within the name input field type logo. This field will be used to save a company’s logo. The logo type will be associated with uploads with image file extensions i.e .jpg and .png.
  8. Create a relation field and within the company’s field name input type representative.
  9. Select User(from: users-permission) from the dropdown and make sure the Company has one user option is selected as shown below.

admin-contnent-relations.png

The relation field is used to create association between two database tables. In our case, we are associating each user with a company. We will be able to get all the user’s details from the user-permissions plugin. The relation we have created ensures that each company has only one user. The representative column in the quote table will be used to store the foreign key which is the user’s id. So each company has one representative in the Job Board application.

Click the Save button and wait for the changes to be applied.

content-types.png

Job Collection

This schema will be used to store job details. It will be associated with a specific company. Follow the steps below to setup the schema.

  1. Click on Content-type Builder under plugins in the side navigation bar.
  2. Click on Create new collection type.
  3. Type Job for the Display Name and click Continue.
  4. Create text fields for the following name, description, type, status and environment.
  5. Create a relation field and within the job application input field type company. Select company from the dropdown towards the right. Ensure that the company has many jobs option is selected as shown below.

job-collection-company.png

The relation above ensures that each job is associated with a company. A single company can have many jobs. The field name jobs will create a link which will be visible from the Company collection.

job-collection-fields.png

Application Collection

This schema will be responsible for storing job application details such as the applicant’s Resume and application status.

  1. Click on Content-type Builder under plugins in the side navigation bar.
  2. Click on Create new collection type.
  3. Type Application for the Display Name and click Continue.
  4. Create a text field named status.
  5. Add two relational fields named job which links to the job collection and the other named applicant which links to the user collection.

edit-applicant.png

The above relation ensures that a user can submit as many applications as they want. Each application is linked to a specific job. A job could have many applications as shown below.

edit-job.png

  1. Create a media field named cv set it to the single type. Finally under the advance setting ensure that only the files option is ticked as shown below.

edit-cv.png

Messaging Collection

This collection will be responsible for saving conversations between a company representative and an applicant.

  1. Click on Content-type Builder under plugins in the side navigation bar.
  2. Click on Create new collection type.
  3. Type Message for the Display Name and click Continue.
  4. Create a JSON field named texts. This field stores all the messages between the conversing parties.
  5. Create a Text field named room. This field will be used to store a string concatenation of the usernames of the parties involved in a conversation. Our implementation will ensure that only two people can be in a room at a time.

message-fields.png

Extending the User Collection

When we created the Strapi project, a schema was generated to allow us to identify the different users that will be using our application. We are going to add more fields that can be considered as KYC parameters for our application.

  1. Click on Content-type Builder under plugins in the side navigation bar.
  2. Click on User which is the last collection under the collection types list.
  3. On the top right click on Add another field.
  4. Click on the Text field button.
  5. Enter first_name as the field’s name. Do not change the default short text option.
  6. Repeat the above step to add fields for last_name and phone_number.
  7. Create a media field of single type. Name it profile. This field will be used to store the user’s profile image.
  8. Click the Save button and wait for the changes to be applied.

user-fields.png

The final configuration of the user’s collection should have an application relation field which was automatically added when we created an association when setting up the application collection. The additional media file field will be used to store the user’s profile image.

Extending Collection functions

After creating collections through the admin interface, Strapi does an excellent job of creating Create, Read, Update and Delete functions associated with each collection. However, we could override the functions and add our own logic. For example we could add some validation to ensure that only users that have a company associated with their profile can manipulate the job collection. In the next steps we are going to be implementing such validations on the application collection, company collection and job collection. We will also override default collection routes and add our custom routes and protect them using Strapi’s user-permissions plugin.

Job

In this collection, we would like create a specific route that will allow companies to view the jobs they post. To implement this, open the job.js file in the controllers dir(./src/api/job/controllers). Add the code below within the createCoreController function block.

    //./src/api/jobs/controllers/job.js
    const { createCoreController } = require('@strapi/strapi').factories;
    module.exports = createCoreController('api::job.job', ({ strapi }) => ({
        async myJobs(ctx) {
            const company = await strapi.db.query('api::company.company').findOne({
                where: { representative: ctx.state.user.id },
                populate: {
                    jobs: {
                        populate: {
                            applications: {
                                populate: {
                                    cv: true,
                                    applicant: {
                                        select: ['username', 'first_name', 'last_name', 'phone_number', 'email', 'id']
                                    }
                                }
                            }
                        }
                    }
                }
            });
            ctx.body = company.jobs;
        },
        async create(ctx) {
            const company = await strapi.db.query('api::company.company').findOne({
                where: { representative: ctx.state.user.id }
            });
            if(company){
    
                    let job = await strapi.entityService.create('api::job.job', {
                        data: {
                            ...ctx.request.body.data,
                            company: company.id
                        }
                    });
    
                    ctx.body = job
        
            }else{
            
              ctx.body = {
              success: false,
              message: "Company not found!"};
              }
        },
        async delete(ctx) {
            const company = await strapi.db.query('api::company.company').findOne({
                where: { representative: ctx.state.user.id }
            });
            const target_job = await strapi.db.query('api::job.job').findOne({
                where: { company: company.id, id: ctx.request.params.id }
            });
            if (target_job != null) {
                const job = await strapi.entityService.delete('api::job.job', target_job.id);
                ctx.body = target_job
            } else {
                ctx.body = {
                    success: false
                }
            }
        }
    }));

The asynchronous function named myJobs utilizes the query engine api to find a company whose representative is the user saved in the connection. We then preload the companies jobs, each job’s applications and their respective applicant details. The next function called create is an override of the default create function. Before a job is created, we first check if the current user is associated with a company. If so we create a job associated with that company. On the delete function we perform the same check before we permanently remove a job record.

Custom Routing Since we added the myJobs function in the controller, we need to map it to a route. This will enable the function to be trigger when an HTTP request is made to the route. In the job directory, under the routes folder create two files as shown below. The first file will be used to plugin in our custom route to the job collection router. Since code is run sequentially, our custom route will be loaded first then the default routes afterwards.

vs-code-folders.png

Add the code below in the job-1.js file. The code defines an endpoint at localhost:1337/api/jobs/mine. The endpoint is a GET HTTP request that will trigger the myJobs function we had created in the controller.

    //job-1.js
    module.exports = {
        routes: [
                  {   
                      method: 'GET',
                      path: '/jobs/mine',
                      handler: 'job.myJobs'
                  }
        ]
    }

The job-2.js file will contains the default collection router function.

    //job-2.js
    const { createCoreRouter } = require('@strapi/strapi').factories;
    module.exports = createCoreRouter('api::job.job'); 

Company

We are going to automatically link the user in the connection to a company. This is happen after signing up. We will use the entity service API to upload a file to the record being created. The file in the company context is the company’s logo. View the code below to see how it will be implemented.

    //./src/api/company/controllers/company.js
    const { createCoreController } = require('@strapi/strapi').factories;
    module.exports = createCoreController('api::company.company', ({ strapi }) => ({
        async create(ctx) {
            const files = ctx.request.files;
            let company = await strapi.entityService.create('api::company.company', {
                data: {
                    ...ctx.request.body,
                    representative: ctx.state.user.id
                },
                files
            });
            ctx.body = company;
        },
        async myProfile(ctx) {
            const company = await strapi.db.query('api::company.company').findOne({
                where: { representative: ctx.state.user.id },
                populate: { logo: true },
            });
            ctx.body = company;
        }
    }));

The myProfile function is a custom function that will load the company’s profile on a dedicate route. It queries the collection with the current user’s details then preloads the company’s logo. The function is mapped to a GET HTTP request as shown below. The endpoint is localhost:1337/api/companies/me.

    //./src/api/company/routes/company-1.js
    module.exports = {
        routes: [{ 
                method: 'GET',
                path: '/companies/me',
                handler: 'company.myProfile',
            }]
    }

Applications

In this collection, we want to be able to process resume uploads and link them to the current user. We also want to initiate a conversation once an application has been updated to the status ‘accepted’ by the company’s representative. We will also implement a custom controller function that will enable a user to load all their applications. The function will be named mine and it will use the query engine API to fetch all applications associated with the current user. It will also preload the job the applied for, the file they attached to the application, the name of the company the created the job and its logo.

    //./src/api/application/controllers/application.js
    const { createCoreController } = require('@strapi/strapi').factories;
    module.exports = createCoreController('api::application.application', ({ strapi }) => ({
        async create(ctx) {
            const files = ctx.request.files;
            let application = await strapi.entityService.create('api::application.application', {
                data: {
                    ...ctx.request.body,
                    applicant: ctx.state.user.id
                },
                files
            });
            ctx.body = application
        },
        async update(ctx) {
            const updated_application = await strapi.entityService.update('api::application.application', ctx.request.params.id, {
                data: ctx.request.body
            });
            if (ctx.request.body.status == "accepted") {
                let application = await strapi.entityService.create('api::message.message', {
                    data: {
                        room: `${ctx.state.user.username}_${ctx.request.body.job}_${ctx.request.body.applicant_username}`,
                        texts: [
                            {
                                source: 0,
                                text: "Application accepted",
                                id: Date.now()
                            }
                        ]
                    }
                });
            }
            ctx.body = updated_application;
        },
        async mine(ctx) {
            const applications = await strapi.db.query('api::application.application').findMany({
                where: { applicant: ctx.state.user.id },
                orderBy: { id: 'DESC' },
                populate: {
                    job: {
                        populate: {
                            company: {
                                populate: {
                                    logo: true
                                }
                            }
                        }
                    },
                    cv: true
                },
            });
            ctx.body = applications;
        }
    }));

The custom function named mine is linked to the endpoint localhost:1337/api/applications/mine through a GET HTTP request. Like before, custom routes are loaded first then default collection routes.

    //./src/api/application/routes/routes-1.js
    module.exports = {
        routes: [
            { 
                method: 'GET',
                path: '/applications/mine', 
                handler: 'application.mine',
            }  
        ]
    }

We could confirm whether our routes have been recognized by running the command below on the command line (terminal/cmd).

    npm run strapi routes:list
    # OR
    yarn strapi routes:list

routes-list.png

The admin interface to check whether our custom routes have been loaded. We will ensure that all our custom routes are accessed by authenticated user only.

  1. On the side navigation bar click the settings button.
  2. Click on roles on the sub menu that pops up then select role named ‘Authenticated’
  3. Select one of the collections we overrode and ensure that checkboxes that correspond to our controller functions are checked as shown below.

app-permissions.png

Socket setup and Authentication

Our job board application will have a messaging feature which will allow an applicant and a company representative to communicate. This feature will rely on the socket.IO server library which will provide an event driven bi-directional communication model allows us to build this real time messaging feature. Use the command below to install the library within our Strapi project’s package.json file.

    npm install socket.io
    # OR
    yarn add socket.io

The socket needs to be instantiated before the server starts. We will attach it to our instance’s address and port number. Open ./src/index.js, the file contains functions that run before the Strapi application is started. We are going to add our code within the bootstrap function block. We will not specify the Cross-Origin Resource Sharing (CORS) object so that connections can be made from any address and port. If you are expecting a connection from a single know source it is advisable to add its address. Limiting CORS addresses helps prevent unauthorized access to sensitive data and resources on the server. Only allowed domains are allowed to make cross-origin requests to the server.

Within the bootstrap function block, we directly call and initialize the socket.io library we installed. We then specify the request type that will be used by the HTTP long polling transport method. Before we allow a client to connect, we verify their authentication token first. The token contains the user’s id as its payload. Therefore decoding the token will output an object that contains the which will allow us to fetch the user’s data from the database using the entityService API. When a user is successfully fetched from the db, we save their details on the socket connection otherwise the socket connection will fail.

    //./src/index.js
    module.exports = {
      
    bootstrap({ strapi }) {
    let interval;
    let io = require('socket.io')(strapi.server.httpServer, {
          cors: {
            origin: "*",
            methods: ["GET", "POST"]
          }
    });
    
    io.use(async (socket, next) => {
    try {
    
      //Socket Authentication
      const result = await strapi.plugins['users-permissions'].services.jwt.verify(socket.handshake.auth.token);
      const user = await strapi.entityService.findOne('plugin::users-permissions.user', result.id, {
    fields: ['first_name', 'last_name', 'email', 'id', 'username', 'phone_number']
            });
            //Save the User to the socket connection
            socket.user = user;
            next();
          } catch (error) {
            console.log(error);
          }
    
        }).on('connection', function (socket) {
          if (interval) {
            clearInterval(interval);
          }
          interval = setInterval(async () => {
            try {
              const entries = await strapi.entityService.findMany('api::message.message', {
                filters: {
                  $or: [
                    {
                      room: {
                        $endsWith: `_${socket.user.username}`,
                      }
                    },
                    {
                      room: {
                        $startsWith: `${socket.user.username}_`,
                      }
                    },
                  ],
                },
                sort: { createdAt: 'DESC' },
                populate: { texts: true },
              });
    
              io.emit('messages', JSON.stringify({ "payload": entries })
              ); // This will emit the event to all connected sockets
    
    
            } catch (error) {
              console.log(error);
            }
    
          }, 2500);
    
          socket.on('send_message', async (sent_message) => {
    
            const message = await strapi.db.query('api::message.message').findOne({
              where: { room: sent_message.room },
              populate: { texts: true },
            });
    
    
            let new_text = message.texts;
            new_text.push(
              {
                "text": sent_message.text,
                "source": socket.user.id,
                "created": new Date().getTime(),
                "id": generateUUID()
              }
            )
    
            const entry = await strapi.db.query('api::message.message').update({
              where: { room: sent_message.room },
              data: {
                texts: new_text,
              },
            });
    
     io.to(sent_message.room).emit("room_messages", JSON.stringify({ message: entry }));
          });
    
          socket.on('join_room', async (sent_message) => {
            socket.join(sent_message.room);
            const entry = await strapi.db.query('api::message.message').findOne({
              where: { room: sent_message.room },
              populate: { texts: true },
            });
    
            io.to(sent_message.room).emit("room_messages", JSON.stringify({ message: entry }));
    
          });
    
          socket.on('exit_room', (message) => {
            socket.leave(message.room);
          });
    
          socket.on('disconnect', () => {
            clearInterval(interval);
            console.log('user disconnected');
          });
    
        });
        return strapi
      },
    };

Within the connection event listener, we defined a set of functions that will be triggered when specific events are made by the client. Since we are build a real time chat, we defined an event named ‘join_room’ which will allow two clients to join and share a room and the ‘send_message’ event which will be responsible for allowing clients to send messages to a specific room.

Within the same socket connection event function block we broadcast all available messages after every 2.5 seconds using the setInterval function. We use the entityService API to filter the records based on the client’s username. Since this event is broadcasted to every device the filtering enables us to only show the most relevant messages to the client. Within the disconnect event, we clear the interval to help prevent unnecessary resource usage and free up server instance memory.

Initializing the SwiftUI project

We need to have Xcode installed on our development machine in order to get started using SwiftUI. Since SwiftUI only target Apple platform, we need to use a Mac. This will enable us to emulate devices such as iPhones, iPads and Apple Watches. You can download Xcode through Mac’s App Store or its website.

Once you’ve successfully installed it and opened it, you’ll be greated by the screen below. Click create new Xcode Project, Select iOS App and give it a product name of JobBoard.

swift.png

Socket IO setup

We are going to install socket io’s swift client package into our iOS project. To get started, click the file button on the toolbar of Xcode’s window. Then click add new packages from the list that pops up.

socket-io-setup.png

The package we are going to install is open source and is actively maintained by developers at socket io. We are going to use the package’s GitHub repository link to direct Xcode on where it should fetch the source code for the package.

Within the modal’s search bar paste socket io’s github repository link and press enter, Xcode will try resolve the address and a README.md file will be rendered once complete. Click add package on the bottom right of the modal to complete the package installation.

search-sources.png

Setting up Network Services and Models

We are going to create classes that will be used to make HTTP and socket requests. Create a folder named Helpers and create the following files within it NetworkService, AuthPersistor, NetworkModels and SocketService. These files will contain the .swift file extension. By Default, Xcode does not show the file extension within the projects file structure but will show the file type logo before the file’s name.

vs-code-swift-folder.png

Authentication Flow

Within the Helpers folder we create a file that will be used to save the Jwt token we receive from the strapi instance during authentication. We will be using iOS keychain feature to handle persistency. Keychain is mainly used to store sensitive information such as passwords and authentication keys. Keychain is an encrypted database that is used by apple devices to store passwords. The database is locked when the device is locked and unlocked when the device is unlocked. This will ensure that the token we received is safe and cannot be accessed by unauthorised parties.

We are going to create a class that will contain functions that will help us access Keychain. We will mark it as final so that it can't be overridden or modified. We will then initialise a static class constructor named standard. The static keyword ensures that the variable belongs to the type rather than a specific instance of that type. This means that every instance of that class will share the object named standard rather than have each one define their own.

    //Helpers/AuthPersistor.swift
    import Foundation
    import Combine
    
    final class KeychainHelper {
        
        static let standard = KeychainHelper()
        private init() {}
        
        func save(_ data: Data, service: String, account: String) {
    
            let query = [
                kSecValueData: data,
                kSecAttrService: service,
                kSecAttrAccount: account,
                kSecClass: kSecClassGenericPassword
            ] as CFDictionary
    
            // Add data in query to keychain
            let status = SecItemAdd(query, nil)
    
            if status == errSecDuplicateItem {
                // Item already exist, thus update it.
                let query = [
                    kSecAttrService: service,
                    kSecAttrAccount: account,
                    kSecClass: kSecClassGenericPassword,
                ] as CFDictionary
    
                let attributesToUpdate = [kSecValueData: data] as CFDictionary
    
                // Update existing item
                SecItemUpdate(query, attributesToUpdate)
            }
        }
        
        func read(service: String, account: String) -> Data? {
            
            let query = [
                kSecAttrService: service,
                kSecAttrAccount: account,
                kSecClass: kSecClassGenericPassword,
                kSecReturnData: true
            ] as CFDictionary
            var result: AnyObject?
            SecItemCopyMatching(query, &result)
            return (result as? Data)
        }
        
        func delete(service: String, account: String) {
            let query = [
                kSecAttrService: service,
                kSecAttrAccount: account,
                kSecClass: kSecClassGenericPassword,
                ] as CFDictionary
            // Delete item from keychain
            SecItemDelete(query)
        }
    }
    
    extension KeychainHelper {
        func save<T>(_ item: T, service: String, account: String) where T : Codable {        
            do {
                // Encode as JSON data and save in keychain
                let data = try JSONEncoder().encode(item)
                save(data, service: service, account: account)
            } catch {
                assertionFailure("Fail to encode item for keychain: \(error)")
            }
        }
        
    func read<T>(service: String, account: String, type: T.Type) -> T? where T : Codable {
            // Read item data from keychain
            guard let data = read(service: service, account: account) else {
                return nil
            }
            // Decode JSON data to object
            do {
                let item = try JSONDecoder().decode(type, from: data)
                return item
            } catch {
                assertionFailure("Fail to decode item for keychain: \(error)")
                return nil
            }
        }
    }

The class contains three functions namely read, delete and save. Each function relies on CFDictonaries to represent data that can be stored with the Keychain database. CFDictonaries are key-value pairs similar to swift’s NSDictionary. The save function is used to upset items. We added a check to handle cases where the key already exists. Values if existing keys will be updated.

We then extended the class’ functionality to cater for JSON encodeable and decodeable objects using the extension keyword. The keyword adds new functionality to existing classes, structures, enumerations and protocol types. It is mainly used to extend data types which we don't have direct access to. For the added functionality to work flawlessly, the object passed to the read and save functions must conform to the codable protocol which transform the objects to JSON format which can be stored easily in the Keychain Database. We added an error handling exception that will prevent our application from crashing if the object passed does not conform to the said protocol.

We will be using data models to structure the data received from the strapi server. Within the project directory, create a new folder named Models and create the following swift files: Job, JobApplication, Company, Message and User.

The Job file sets up a struct that conforms to both the codable and identifiable protocols. The struct named job contains two inializers one of which will be used to decode JSON data from strapi to build and instance of the struct using the data retrived. The initlizer contains JSON decoder keys that will be used to fetch deeply embedded JSON objects.

    //Models/Job.swift
    struct Job : Codable, Identifiable{
        
    var id : Int
    var name : String
    var description : String
    var company : Company? = nil
    var type : String //Contract/Long Term/Short Term/Internship/Consultancy
    var environment : String //Remote/Semi-remote/In-Office
    var status : String
        
    init(id: Int, name : String, description: String, type: String, environment: String, status: String){
     self.id = id
     self.name = name
     self.description = description
     self.type = type
     self.environment = environment
     self.status = status
    }
            
    private enum JobDataKeys: String, CodingKey {
     case id = "id",attributes = "attributes"
    enum AttributeKeys : String, CodingKey {
     case name = "name",
      description = "description",
      company = "company",
      type="type",
      environment = "environment", 
      status = "status"
                
     enum CompanyKey : String, CodingKey{
        case data = "data"
        enum CompanyDataKeys : String, CodingKey {
        case id = "id", attributes = "attributes"
        enum CompanyDataAttributesKeys : String, CodingKey{
        case address = "address",
            bio = "bio",
          category = "category",
            email = "email",
             name = "name", 
            phone = "phone",
             logo = "logo"
                            
     enum CompanyLogoKey : String, CodingKey{
     case data = "data"                    
     enum CompanyLogoAttributes : String, CodingKey {
     case attributes = "attributes"
     enum CompanyLogoAttributeKeys : String, CodingKey {
     case url = "url", formats = "formats"
     enum CompanyLogoAttributeFormatsKeys : String, CodingKey {
      case large = "large", 
      medium = "medium",
      small = "small", 
      thumbnail = "thumbnail", 
      url = "url"
      enum CompanyLogoFormartsLarge: String, CodingKey{case url = "url" }
      enum CompanyLogoFormartsThumbnail: String, CodingKey{case url = "url"}
      enum CompanyLogoFormartsSmall: String, CodingKey{case url = "url"}
      enum CompanyLogoFormartsMedium: String, CodingKey{case url = "url"}
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }

The Company.swift file defines the struct that defines how a company’s information will be structured within our Job Board Application. The file below shows how the struct is defined. Both the Company and Company logo structs conform to the codable protocols which ensures that they can be transform to and from JSON format.

    //Company.swift
    import Foundation
    struct Company : Codable{
        var id: Int
        var name : String
        var phone : String
        var email : String
        var address : String
        var category : String //Tech/Pharma/Transport/NGO/Finance
        var bio: String
        var logo : CompanyLogo?
        init(id: Int, name: String, phone: String, email: String, address: String, category: String, bio: String, logo: CompanyLogo?) {
            self.id = id
            self.name = name
            self.phone = phone
            self.email = email
            self.address = address
            self.category = category
            self.bio = bio
            self.logo = logo
        }
    }
    
    struct CompanyLogo : Codable{
        var url : String
        var thumbnail : String
        var small : String
        var medium : String
        var large : String
        init(url: String, thumbnail: String, small: String, medium: String, large: String) {
            self.url = url
            self.thumbnail = thumbnail
            self.small = small
            self.medium = medium
            self.large = large
        }
    }

User Model

This file defines the struct that will be used to represent our user’s information. User details can be converted to and from JSON since the struct conforms to the Codable protocol. The file also contains an AuthState struct which will listen to changes in authentication states by utilising swift’s PubSub system. The struct also utilizes the KeyChainHelper class we created. When a user is authenticated, their details are persisted in the Key chain database. We are storing it there because the jwt token is included within the user struct.

    //Models/User.swift
    import Foundation
    import Combine
    
    struct User: Codable{
        var username : String
        var id : Int
        var phone_number : String
        var email : String
        var first_name : String
        var last_name : String
        var token : String
        var profile : ProfileImage? = nil
        var role : Role? = nil
    }
    
    struct ProfileImage : Codable {
        var small : String = ""
        var medium : String = ""
        var large : String = ""
        var thumbnail : String = ""
        var url : String = ""
    }
    
    struct Role: Codable{
        var name : String
        var description: String
        private enum RoleKeys : String, CodingKey {
            case name = "name", description = "description"
        }
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: RoleKeys.self)
            self.name = try container.decode(String.self, forKey: .name)
            self.description = try container.decode(String.self, forKey: .description)
        }
    }
    
    struct AuthState{
        static let Authenticated = PassthroughSubject<Bool, Never>()
        static let Company = PassthroughSubject<Bool, Never>()
        static func IsAuthenticated() -> Bool {
        let user = KeychainHelper.standard.read( 
            service: "strapi_job_authentication_service", 
            account: "strapi_job_app", 
            type: User.self)
            NetworkService.current_user = user
            return user != nil
        }
    
      static func IsCompany() -> Bool {
      let company = KeychainHelper.standard.read( 
        service: "strapi_job_company_service",
        account: "strapi_job_app", 
        type: MyApplicationJobCompany.self)
        NetworkService.company = company
        return company != nil
        }
    }

Network Response Models

Since SwiftUI is a statically typed language, we need to declare variable data types before compilation. We also need to parse the data from the strapi server into a swiftUI data types. We will create a file that will handle JSON parsing after successful network call. The file will contain structs that could be used by POST and GET request. It also contains file uploader structs for various file types.

    //NetworkModels.swift
    import Foundation
    import PhotosUI
    import PDFKit
    
    struct BulkJobServerResponse: Decodable {    
     var data : [Job]
     enum DataKeys: CodingKey {
      case data
     } 
     init(from decoder: Decoder) throws {
      let container = try decoder.container(keyedBy: DataKeys.self)   
      self.data = try container.decode([Job].self, forKey: .data)
     }
    }
    
    struct AuthenticationResponse :  Codable{
        
     var user : User
     enum AuthResponseKeys: String, CodingKey {
      case jwt = "jwt", user = "user"
       enum UserDetailsKeys : String, CodingKey {
         case id = "id", username = "username", email = "email", first_name = "first_name", last_name = "last_name", phone_number = "phone_number"
       }
     }
        
     init(from decoder: Decoder) throws {
      let authReponseContainer = try decoder.container(keyedBy: AuthResponseKeys.self)
      let userDetailsContainer = try authReponseContainer.nestedContainer(keyedBy: AuthResponseKeys.UserDetailsKeys.self, forKey: .user)
      let id = try userDetailsContainer.decode(Int.self, forKey: .id)
      let phone_number = try userDetailsContainer.decode(String.self, forKey: .phone_number)
      let username = try userDetailsContainer.decode(String.self, forKey: .username)
      let first_name = try userDetailsContainer.decode(String.self, forKey: .first_name)
      let last_name = try userDetailsContainer.decode(String.self, forKey: .last_name)
      let email = try userDetailsContainer.decode(String.self, forKey: .email)
      let jwt = try authReponseContainer.decode(String.self, forKey: .jwt)
      self.user = User(username: username, id: id, phone_number: phone_number, email: email, first_name: first_name, last_name: last_name, token: jwt )
        }
    }
    
    struct UploadImage {
        let key: String
        let filename: String
        let data: Data
        let mimeType: String
        init?(withImage image: UIImage, forKey key: String) {
            self.key = key
            self.mimeType = "image/jpeg"
            self.filename = "imagefile.jpg"
            guard let data = image.jpegData(compressionQuality: 0.7) else { return nil }
            self.data = data
        }
    }
    
    struct UploadPDF {
        let key: String
        let filename: String
        let data: Data
        let mimeType: String
        init?(withPDF pdfdoc: PDFDocument, forKey key: String) {
            self.key = key
            self.mimeType = "application/pdf"
            self.filename = "document.pdf"
            self.data = pdfdoc.dataRepresentation()!
        }
    }

Authentication

There are two types of users, a normal user and a company user. A company user has a company profile associated with it. They have the capability to add new jobs and view job applications of the jobs they created.

authentication.png

When the company user type is selected, we immediately create a company and associate it with the newly created user. They can then modify the company details on the profile tab once authenticated.

    //Register Function
    func register(first_name:String, last_name: String, username: String,email:String, phone:String, password: String, completion: @escaping (User?) -> ()  ){
            
    guard  let url = URL(string: "\(authentication_url)/local/register")  else {
        completion(nil)
        fatalError("Missing URL") 
    }
            
    var urlRequest = URLRequest(url: url)
    urlRequest.httpMethod = "POST"
    urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
    let parameters: [String: Any] = [
                "username": username,
                "password": password,
                "email" : email,
                "first_name": first_name,
                "last_name": last_name,
                "phone_number": phone
            ]
    do {
      // convert parameters to Data and assign dictionary to httpBody of request
      urlRequest.httpBody = try JSONSerialization.data(withJSONObject: parameters)
      print(urlRequest)
    } catch let error {
      assertionFailure(error.localizedDescription)
      completion(nil)
      return
    }
            
    let dataTask = URLSession.shared.dataTask(with: urlRequest) { (data, response, error) in
    
    if let error = error {
     print("Request error: ", error)
     completion(nil)
     return
    }
     // ensure there is data returned
    guard let responseData = data else {
     assertionFailure("nil Data received from the server")
     completion(nil)
     return
    }
    
    do {
    
    let loaded_user = try JSONDecoder().decode(AuthenticationResponse.self, from: responseData)
    
    KeychainHelper.standard.save(loaded_user.user, service: "strapi_job_authentication_service",account: "strapi_job_app")
    
    NetworkService.current_user = loaded_user.user
    completion(loaded_user.user)
                    
    } catch let DecodingError.dataCorrupted(context) {
     print(context)
     completion(nil)
    } catch let DecodingError.keyNotFound(key, context) {
     print("Key '\(key)' not found:", context.debugDescription)
     print("codingPath:", context.codingPath)
     completion(nil)
    } catch let DecodingError.valueNotFound(value, context) {
     print("Value '\(value)' not found:", context.debugDescription)
     print("codingPath:", context.codingPath)
     completion(nil)
    } catch let DecodingError.typeMismatch(type, context)  {
     print("Type '\(type)' mismatch:", context.debugDescription)
     print("codingPath:", context.codingPath)
     completion(nil)
    } catch let error {
     assertionFailure(error.localizedDescription)
     completion(nil)
     }
     }
     dataTask.resume()
    }

All Network Requests call functions are defined in the NetworkService.swift file. Each view instantiates the network service object then calls the appropriate function within the .onAppear modifier which is an view instance method that we are utilising to fetch data when a view has been loaded on the device screen.

Company Context

This part manages everything associated with a company. It allows a user to update their company profile, view, approve and decline applications, create, view, update and delete Job posts.

To access this context, the user must navigate to the profiles tab and he/she will see the company profile button just above the log out button. This button is not available to user’s without a company associated to their user account.

ipone-view-1.png

Company Jobs

This section contains functionalities to do CRUD actions on the job collection we had created. Since we had overridden the job collection, our code checks whether a certain job is associated with the company and user. This prevents unauthorised people from updating and deleting jobs that are not associated to a company that is liked to their user profile.

iphone-view-2.png

Socket Communication

We need to consume the socket connection we had created using Socket IO on the strapi server. Below is a simple swift client side class definition that includes authentication. The class is used to facilitate real time messaging between the applicant and company representative. It also contains function which assist us in various socket io functionalities for example listening to broadcasts, joining rooms and emitting events.

    //SocketService.swift
    final class SocketService : ObservableObject{
        private var manager = SocketManager(socketURL: URL(string: "ws://127.0.0.1:1337")!, config: [ .compress])
        @Published var socket_messages : [SocketMessage] = []
        @Published var room : SocketMessage = SocketMessage(id: 0, room: "", texts: [])
        let socket : SocketIOClient
        init(){
            self.socket = manager.defaultSocket
            self.socket.on(clientEvent: .connect, callback: {data, ack in print("Connected") })
            self.socket.on("messages") {data, ack in
                guard let cur = data[0] as? String else { return }
                let jsonObjectData = cur.data(using: .utf8)!
                do {
                    let candidate  =  try JSONDecoder().decode(
                        MM.self,
                        from: jsonObjectData
                    )
                    self.socket_messages = candidate.payload
                } catch let DecodingError.dataCorrupted(context) {
                    print(context)
                    self.socket_messages = []
                } catch let DecodingError.keyNotFound(key, context) {
                    print("Key '\(key)' not found:", context.debugDescription)
                    print("codingPath:", context.codingPath)
                    self.socket_messages = []
                } catch let DecodingError.valueNotFound(value, context) {
                    print("Value '\(value)' not found:", context.debugDescription)
                    print("codingPath:", context.codingPath)
                    self.socket_messages = []
                } catch let DecodingError.typeMismatch(type, context)  {
                    print("Type '\(type)' mismatch:", context.debugDescription)
                    print("codingPath:", context.codingPath)
                    self.socket_messages = []
                } catch let error {
                    assertionFailure(error.localizedDescription)
                    self.socket_messages = []
                }            
            }
            self.socket.on("room_messages") {data, ack in
                guard let cur = data[0] as? String else { return }
                let jsonObjectData = cur.data(using: .utf8)!
                do {
                    let room_details  =  try JSONDecoder().decode(SocketMessage.self,from: jsonObjectData)
                    self.room = room_details
                } catch let DecodingError.dataCorrupted(context) {
                    print(context)
                    self.room = SocketMessage(id: 0, room: "", texts: [])
                } catch let DecodingError.keyNotFound(key, context) {
                    print("Key '\(key)' not found:", context.debugDescription)
                    print("codingPath:", context.codingPath)
                    self.room = SocketMessage(id: 0, room: "", texts: [])
                } catch let DecodingError.valueNotFound(value, context) {
                    print("Value '\(value)' not found:", context.debugDescription)
                    print("codingPath:", context.codingPath)
                    self.room = SocketMessage(id: 0, room: "", texts: [])
                } catch let DecodingError.typeMismatch(type, context)  {
                    print("Type '\(type)' mismatch:", context.debugDescription)
                    print("codingPath:", context.codingPath)
                    self.room = SocketMessage(id: 0, room: "", texts: [])
                } catch let error {
                    assertionFailure(error.localizedDescription)
                    self.room = SocketMessage(id: 0, room: "", texts: [])
                }
            }
            socket.connect(withPayload: ["token": NetworkService.current_user!.token])
        }
        
        func sendMesage(room_name: String, message : String) {
            self.socket.emit("send_message", ["room": room_name, "text": message])
        }
        
        func joinRoom(room_name: String){
            self.socket.emit("join_room", ["room": room_name])
        }
        
        func exitRoom(room_name: String){
            self.socket.emit("exit_room", ["room": room_name])
        }
    }

The list of messages will be automatically refreshed and the other user will receive messages as soon as the company representative has pressed send. A conversation can only be initiated when a job application has been reviewed and accepted by the company representative. They can then reach out through our messaging feature.

message-feature.png

We are consuming the socket connection within two views namely MessageView.swift and MessageDetail.swift. The code below shows how we structured MessageView.swift. Since the SocketService class conforms to the [**ObservableObject**](https://developer.apple.com/documentation/combine/observableobject) protocol, we must use the property wrapper @StateObject every time we are creating its instance. Since we had set the server to broadcast messages after every 2.5 seconds, the UI will update once new changes are received.

    //MessageView.swift
    import SwiftUI
    import SocketIO
    
    struct MessageView: View {
        @StateObject var service = SocketService()
        static let tag: String? = "MessageView"
        var body: some View {
            NavigationView{
                List(){
                    ForEach(service.socket_messages){message in
                        NavigationLink(destination: MessageDetailView(
                            socketMessage: message,
                            socket: service
                        )) {
                            Text(message.receiver)
                        }
                    }
                }.navigationTitle("My Messages")
            }
        }
    }

Job

Once a user has logged in, they are greeted by a list of available jobs. They can then click their preferred job advert and upload their cvs. Immediately the view is loaded, we make a GET api call to the endpoint localhost:1337/api/jobs using the NetworkService object within the onAppear view modifier. Since we had already logged in, the object will automatically append the token to the request’s headers.

    //HomeView.swift
    import SwiftUI
    
    struct HomeView: View {
        private var network = NetworkService()
        static let tag: String? = "HomeView"
        @State private var jobs : [Job] = []
        var body: some View {
            NavigationView{
                List(jobs) { job in
                    NavigationLink {
                        DetailView(job: job)
                    } label: {
                        JobCard(job: job)
                    }
                }.onAppear{
                    network.listJobs{fetched_jobs in
                        jobs = fetched_jobs
                    }
                    network.loadMyCompanyProfile{company_profile in
                        if company_profile != nil{
                          KeychainHelper.standard.save(company_profile, service: "strapi_job_company_service", account: "strapi_job_app")
                            NetworkService.company = company_profile
                            AuthState.Company.send(true)
                        }
                    }
                    
                }
                .navigationTitle("Jobs")
            }}
    }
    
    struct HomeView_Previews: PreviewProvider {
        static var previews: some View {
            HomeView()
        }
    }

Since we had set the current_user variable as static while defining the NetworkService class, all instance of the class will have the jwt token which is stored in the current_user struct. The struct is updated using pubs during authentication and de-authentication.

Conclusion

In this tutorial we developed a Job Board application powered by Strapi and SwiftUI. We went through how to extend default collections functionalities. We added custom routes to strapi collections and attached Socket IO to the strapi server instance to allow realtime connections.

You can download the source code from the following repositories:

  1. SwiftUI Frontend
  2. Strapi Backend
Demystifying Strapi's Populated & Filtering
BlogIntermediate·27 min read

Demystifying Strapi's Populate & Filtering

How to query, populate, and filter data in Strapi.

·March 9, 2026
Top 5 Best Resource To Learn Next.Js and Strapi.png
BlogBeginner·9 min read

Top 5 Best Resource To Learn Next.Js and Strapi

In this post, we’re highlighting the Top 5 Best Resources to master both Next.js and Strapi. Keeping up with the latest resources to learn and continuously improve your Next.js skills can be overwhelming, whether you’re just starting with Next.js or have been developing apps with Next.js for a while.

·May 5, 2025
How To Migrate Your Project From Strapi 4 to Strapi 5
Blog·20 min read

How To Migrate Your Project From Strapi 4 to Strapi 5

Migrating from Strapi v4 to v5 can seem daunting, but it becomes a manageable process with the right approach and...

·October 1, 2024