computer laptop statistics illustration

Cockpit CMS Tutorial Part 3: Displaying Content

We've moved from setting up Cockpit CMS, setting up your first users, roles and the API to creating your first content piece. Now it's time to display the output. Remember, as stated earlier on, Cockpit CMS is a headless CMS. It does not come with any frontend section to display what you have published in the admin backend. That is best left to yout as the developer to figure out. What Cockpit CMS does is that it merely exposes API endpoints (either REST or GraphQL) for you to fetch data from. This means that we will need to have soem sort of HTTP Client that could act as the 'Transport layer' between the Cockpit CMS instance and your frontend of choice. We'll give 3 examples based on PHP, Go and Rust (the Go and Rust will be pretty brief).

NOTE: We are assumming you have installed and setup your Cockpit CMS instance at http://yourcockpitcm.com/ and your app(s) where you will be displaying the API results will be at http://yourwebsite.com.

Displaying your Blog Posts using PHP

We'll use Symfony here just to get things off a bit faster. Symfony is one of the major PHP frameworks that comes with all the batteries included. It's component-based design makes it suitable to create any kind of application without the bloat; you just need to select the components you need and build on them.

Let's spin up a new 'Web' skeleton application based on Symfony. Use the commands below:

	
composer create-project symfony/skeleton:"7.4.*" my_project_directory
cd my_project_directory
composer require webapp
	

This will set up the basic building blocks for a Symfony web application. It comes with the handy Symfony HTTP Client library which you can use. But let's deviate a bit here and use Guzzle's HTTPClient. Guzzle HTTPClient is framework-agnostic; Symfony's HTTP Client will just work fine.

Now create your first basic Symfony controlller in the 'src/Controller' directory. We'll call it 'AppController.php' and use this code for the it:

	
namespace App\Controller;

use GuzzleHttp\Client;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class AppController extends AbstractController
{
    const API_KEY = 'Apikey';//From your Cockpit CMS instance

    #[Route('/posts', name: 'posts_index')]
    public function index(): Response
    {
        // 1. Create Guzzle client and fetch data
        $client = new Client();
        $response = $client->get(
            'https://yourcockpitcm.com/api/content/items/Blog', [
            'headers' => [
                'api-key' => self::API_KEY
            ],
        ]);
        $posts = json_decode($response->getBody()->getContents(), true);

        // 2. Render Twig template with the posts
        return $this->render('posts/index.html.twig', [
            'posts' => $posts,
        ]);
    }
}
	

Note the constant we've placed at the top. The API key is from our Cockpit CMS instance. These will be very important if we want to consume the output from our 'Blog' content model that we created in Part 2 of this tutorial. PS: This is NOT best practice. The best way would be to set these values in your .env file. We are simply doing this here to give you a quick understanding of how to fetch and display Cockpit CMS content.

Look at the index() method in our code. We're are using Guzzle's HTTPClient to call the full URL to our 'Blog' content model in Cockpit CMS instance: http://yourcockpitcms.com/. Since we are calling an entire 'Collection' content model the API will return a JSON response which we will in turn decode to a plain old PHP array. We'll then assign this array to a variable. Then finally pass this variable to our Twig templates.

How will these items be displayed at the 'View/Template' level. Let's create a basic 'base.html.twig' template in our 'templates' folder. Dump the code below in that file:

	
<!DOCTYPE html>
<html>
<head>
    <title>Posts from API</title>
</head>
<body>
    <h1>Posts</h1>

    {% for post in posts %}
        <div class="post">
            <h3>{{ post.title }}</h3>
            <small>Post #{{ post.id }}</small>
            <p>{{ post.body }}</p>
        </div>
    {% endfor %}
</body>
</html>
	

Note how we are looping in the array passed from our index() method in AppController.php.

That's how simple it is to display content from Cockpit CMS.

If you would like to show a single item from Cockpit CMS you will need to use this URL to the item in the Cockpit CMS: http://yourcockpitcm.com/api/content/item/Blog/{id}. The {id} represents the Cockpit CMS ID number given to the record item in this case a particular blog post.

Then proceed to create a new method in your AppController.php of the form:

	

#[Route('/post/{id}', requirements: ['id' => "[a-z0-9\-]+"], name: 'post_index')]
public function view(): Response
{
    // 1. Create Guzzle client and fetch data
    $client = new Client();
    $response = $client->get(
        'https://yourcockpitcm.com/api/content/item/Blog/'.$id, [
        'headers' => [
            'api-key' => self::API_KEY
        ],
    ]);
    $post = json_decode($response->getBody()->getContents(), true);

    // 2. Render Twig template with the post
    return $this->render('posts/view.html.twig', [
        'post' => $post,
    ]);
}
	

Then create a new template in your 'templates' folder (let's call it single.html.twig) with this code:

	
<!DOCTYPE html>
<html>
<head>
    <title>Post from API</title>
</head>
<body>
    <h1>Post</h1>

    <div class="post">
        <h3>{{ post.title }}</h3>
        <small>Post #{{ post.id }}</small>
        <p>{{ post.body }}</p>
    </div>
</body>
</html>
	

That's it. You can now view both a collection of items and a single item published in your Cockpit CMS backend.

Displaying your Blog Posts using Rust

We won't go much in this. We will use Rust's Axum framework, Tera templating engine and the Reqwest crate. Let's create a self-contained Rust application that consumes the API results from our Cockpit CMS instance.

	
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
    routing::get,
    Router,
};
use serde::Deserialize;
use std::sync::Arc;
use tera::{Context, Tera};
use tower_http::trace::TraceLayer;

// ------------------------- Data models -------------------------
#[derive(Debug, Deserialize)]
struct Post {
    id: String,
    title: String,
    body: String,
}

// ------------------------- Templates (embedded) -------------------------
const LIST_TEMPLATE: &str = r#"
<!DOCTYPE html>
<html>
<head>
    <title>Blog Posts</title>
</head>
<body>
    <h1>All Posts</h1>
    {% for post in posts %}
    <div class="post">
        <h3><a href="/posts/{{ post.id }}">{{ post.title }}</a></h3>
        <small>Post #{{ post.id }}</small>
        <p>{{ post.body }}</p>
    </div>
    {% endfor %}
</body>
</html>
"#;

const DETAIL_TEMPLATE: &str = r#"
<!DOCTYPE html>
<html>
<head>
    <title>Post #{{ post.id }}</title>
</head>
<body>
    <div class="post">
        <h1>{{ post.title }}</h1>
        <small>Post #{{ post.id }}</small>
        <p>{{ post.body }}</p>
    </div>
    <a class="back" href="/posts">&larr; Back to all posts</a>
</body>
</html>
"#;

// ------------------------- Shared state -------------------------
#[derive(Clone)]
struct AppState {
    client: reqwest::Client,
    tera: Tera,
}

// API credentials (JSONPlaceholder ignores them, but we demonstrate basic auth)
const API_PASS: &str = "secret";//From your Cockpit CMS instance
const API_BASE: &str = "https://yourcockpitcm.com/api/content/item/Blog";

// ------------------------- Handlers -------------------------
async fn list_posts(State(state): State) -> impl IntoResponse {
    // Fetch all posts
    let url = API_BASE;
    let response = state
        .client
        .get(url)
        .basic_auth(Some(API_PASS))
        .send()
        .await;

    let posts: Vec = match response {
        Ok(resp) => {
            if resp.status().is_success() {
                match resp.json().await {
                    Ok(data) => data,
                    Err(_) => {
                        return (
                            StatusCode::INTERNAL_SERVER_ERROR,
                            "Failed to parse API response",
                        )
                            .into_response();
                    }
                }
            } else {
                return (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    format!("API error: {}", resp.status()),
                )
                    .into_response();
            }
        }
        Err(e) => {
            return (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("Request failed: {}", e),
            )
                .into_response();
        }
    };

    // Render template
    let mut ctx = Context::new();
    ctx.insert("posts", &posts);

    match state.tera.render("list.html", &ctx) {
        Ok(html) => (StatusCode::OK, html).into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Template error: {}", e),
        )
            .into_response(),
    }
}

async fn show_post(
    State(state): State,
    Path(id): Path,
) -> impl IntoResponse {
    let url = format!("{}/{}", API_BASE, id);
    let response = state
        .client
        .get(&url)
        .basic_auth(Some(API_PASS))
        .send()
        .await;

    let post: Post = match response {
        Ok(resp) => {
            if resp.status().is_success() {
                match resp.json().await {
                    Ok(data) => data,
                    Err(_) => {
                        return (
                            StatusCode::INTERNAL_SERVER_ERROR,
                            "Failed to parse API response",
                        )
                            .into_response();
                    }
                }
            } else if resp.status() == StatusCode::NOT_FOUND {
                return (StatusCode::NOT_FOUND, "Post not found").into_response();
            } else {
                return (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    format!("API error: {}", resp.status()),
                )
                    .into_response();
            }
        }
        Err(e) => {
            return (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("Request failed: {}", e),
            )
                .into_response();
        }
    };

    // JSONPlaceholder returns an empty object `{}` for a non‑existent ID,
    // which deserialises to a Post with id == 0. Treat that as not found.
    if post.id == 0 {
        return (StatusCode::NOT_FOUND, "Post not found").into_response();
    }

    // Render detail template
    let mut ctx = Context::new();
    ctx.insert("post", &post);

    match state.tera.render("detail.html", &ctx) {
        Ok(html) => (StatusCode::OK, html).into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Template error: {}", e),
        )
            .into_response(),
    }
}

// ------------------------- Main -------------------------
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Initialize tracing for logging (optional)
    tracing_subscriber::fmt()
        .with_target(false)
        .compact()
        .init();

    // Build the Tera engine and add raw templates
    let mut tera = Tera::default();
    tera.add_raw_template("list.html", LIST_TEMPLATE)?;
    tera.add_raw_template("detail.html", DETAIL_TEMPLATE)?;

    // Build HTTP client (reused across requests)
    let client = reqwest::Client::new();

    let state = AppState { client, tera };

    let app = Router::new()
        .route("/posts", get(list_posts))
        .route("/posts/{id}", get(show_post))
        .layer(TraceLayer::new_for_http())
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    tracing::info!("Server running on http://localhost:8080");
    axum::serve(listener, app).await?;

    Ok(())
}
	

Then run 'cargo run' and open your website at http://yourwebsite.com:3000/posts & http://yourwebsite.com:3000/posts/{blogPostId} and view the results.

Displaying your Blog Posts using Go

This will also be brief. We will use Go's 'net/http' standard and 'html/template' standard libraries. Let's create a self-contained Go application that consumes the API results from our Cockpit CMS instance.

	
package main

import (
	"encoding/json"
	"html/template"
	"log"
	"net/http"
	"strings"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
)

// Post matches the JSON structure from JSONPlaceholder.
type Post struct {
	ID     string    `json:"id"`
	Title  string `json:"title"`
	Body   string `json:"body"`
}

const (
	apiBaseURL = "https://yourcockpitcm.com/api/content/item/Blog"
	apiPass    = "secret"    // From your Cockpit CMS instance
)

// listTemplate iterates over posts.
const listTemplate = `
<!DOCTYPE html>
<html>
<head>
    <title>Blog Posts</title>
</head>
<body>
    <h1>All Posts</h1>
    {{range .}}
    <div class="post">
        <h3><a href="/posts/{{.ID}}">{{.Title}}</a></h3>
        <small>User {{.UserID}} · Post #{{.ID}}</small>
        <p>{{.Body}}</p>
    </div>
    {{end}}
</body>
</html>
`

// detailTemplate shows one post.
const detailTemplate = `
<!DOCTYPE html>
<html>
<head>
    <title>Post #{{.ID}}</title>
</head>
<body>
    <div class="post">
        <h1>{{.Title}}</h1>
        <small>User {{.UserID}} · Post #{{.ID}}</small>
        <p>{{.Body}}</p>
    </div>
    <a class="back" href="/posts">&larr; Back to all posts</a>
</body>
</html>
`

func main() {
	r := chi.NewRouter()
	r.Use(middleware.Logger) // optional, logs requests

	r.Get("/posts", listPosts)
	r.Get("/posts/{id}", showPost)

	log.Println("Server starting on :8080")
	log.Fatal(http.ListenAndServe(":8080", r))
}

// fetchFromAPI makes a GET request with Basic Auth and decodes the response.
func fetchFromAPI(url string, target interface{}) error {
	client := &http.Client{}
	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return err
	}
	// Set Basic Authentication credentials
	req.SetBasicAuth(apiPass)

	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return err
	}
	return json.NewDecoder(resp.Body).Decode(target)
}

func listPosts(w http.ResponseWriter, r *http.Request) {
	var posts []Post
	if err := fetchFromAPI(apiBaseURL, &posts); err != nil {
		http.Error(w, "Failed to fetch posts: "+err.Error(), http.StatusInternalServerError)
		return
	}

	tmpl, err := template.New("list").Parse(listTemplate)
	if err != nil {
		http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError)
		return
	}
	tmpl.Execute(w, posts)
}

func showPost(w http.ResponseWriter, r *http.Request) {
	idParam := chi.URLParam(r, "id")

	url := apiBaseURL + "/" + idParam
	var post Post
	if err := fetchFromAPI(url, &post); err != nil {
		http.Error(w, "Failed to fetch post: "+err.Error(), http.StatusInternalServerError)
		return
	}
	// If the API returns an empty object for a non-existent ID (JSONPlaceholder returns {}),
	// we treat that as not found.
	if post.ID == 0 {
		http.Error(w, "Post not found", http.StatusNotFound)
		return
	}

	tmpl, err := template.New("detail").Parse(detailTemplate)
	if err != nil {
		http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError)
		return
	}
	tmpl.Execute(w, post)
}
	

Then run 'go run' and open your website at http://yourwebsite.com:3000/posts & http://yourwebsite.com:3000/posts/{blogPostId} and view the results.

Published: 18th, Tuesday, Aug, 2026 Last Modified: 18th, Tuesday, Aug, 2026

7+

Years of Experience

135+

Happy Clients

140+

Web Systems Built