Government agencies are increasingly adopting headless CMS architecture to modernize their digital services while maintaining security and compliance standards. This approach eliminates vendor lock-in, reduces long-term costs, and provides the flexibility to adapt as citizens’ needs evolve across multiple digital channels.
Want to learn how to build a government information portal with a headless CMS? Read on to find out how you can create a production-ready government portal using Strapi for your headless CMS backend and Next.js for the frontend—a combination that gives you both speed and security.
In brief:
- Headless CMS architecture separates content management from presentation, enabling government agencies to deliver consistent information across websites, mobile apps, kiosks, and digital signage simultaneously.
- Strapi provides advantages for government content management, including granular role-based access control, built-in audit logging, and multilingual content management from a unified interface. These features support secure and efficient content management tailored to government needs.
- The implementation process covers four key phases: backend setup with Strapi, frontend development with Next.js, compliance feature integration, and security-hardened production deployment.
- Open-source foundations eliminate licensing fees while maintaining full control over content infrastructure, creating a future-proof solution that adapts to evolving citizen needs.
Why Use Strapi to Build a Government Information Portal
Strapi is ideal for government information portals because it supports secure, multilingual, and omnichannel content delivery, without locking agencies into rigid platforms. Its headless architecture gives public sector teams the flexibility to manage content centrally and distribute it across websites, mobile apps, kiosks, and signage—all while meeting compliance and accessibility requirements.
Here’s why government teams choose Strapi:
- Security-first access control with granular role permissions and audit logging available in the Enterprise plan (v4.6.0+).
- Multilingual content delivery is powered by Strapi’s internationalization features for consistent messaging across languages.
- API-first architecture that integrates easily with legacy systems and modern apps alike
- Vendor-neutral, open-source foundation that eliminates licensing fees and supports long-term flexibility
Strapi’s API-first approach ensures agencies aren’t locked into a single front-end framework or vendor. That makes it easier to stay compliant, adapt to new technologies, and serve citizens wherever they are, without rebuilding from scratch.
How to Build the Backend of a Government Information Portal with Strapi
Building a government-grade Strapi backend requires balancing quick wins with rock-solid security. Start with a minimal proof-of-concept that shows stakeholders what's possible, then build in enterprise features like role-based permissions, security hardening, and compliance controls.
Initiating the Setup
You can begin by installing Strapi and creating your project. The quickstart command sets up a basic instance:
1# Install Strapi globally
2npm install -g strapi
3
4# Create a new Strapi project for your government portal
5npx create-strapi-app government-portal --quickstart
Once installed, Strapi automatically launches and prompts you to create an admin user. This initial setup provides a foundation to demonstrate to stakeholders before implementing more advanced configurations.
Creating Government-Specific Content Models
The secret is designing content models that reflect real government structures—departments, services, announcements, and documents—while implementing workflows that mirror typical government approval processes.
Here's an example of defining a "Public Service" content type via the API:
1// Path: /api/public-service/content-types/schema.json
2{
3 "kind": "collectionType",
4 "collectionName": "public_services",
5 "info": {
6 "singularName": "public-service",
7 "pluralName": "public-services",
8 "displayName": "Public Service",
9 "description": "Government services available to citizens"
10 },
11 "options": {
12 "draftAndPublish": true
13 },
14 "attributes": {
15 "title": {
16 "type": "string",
17 "required": true
18 },
19 "description": {
20 "type": "richtext",
21 "required": true
22 },
23 "department": {
24 "type": "relation",
25 "relation": "manyToOne",
26 "target": "api::department.department"
27 },
28 "serviceHours": {
29 "type": "component",
30 "component": "scheduling.service-hours",
31 "repeatable": true
32 },
33 "requiredDocuments": {
34 "type": "component",
35 "component": "documents.document-list",
36 "repeatable": true
37 },
38 "contactInformation": {
39 "type": "component",
40 "component": "contacts.contact-details"
41 },
42 "serviceLocations": {
43 "type": "relation",
44 "relation": "manyToMany",
45 "target": "api::location.location"
46 }
47 }
48}
This approach ensures your CMS handles everything from routine service updates to emergency communications with proper oversight.
Implementing Role-Based Permissions
Government workflows require strict role definitions. Configure custom roles that match actual department structures:
1// Example of creating custom roles through the Strapi API
2const createRoles = async () => {
3 try {
4 // Define roles with specific permissions
5 const roles = [
6 {
7 name: 'Department Editor',
8 description: 'Can create and edit content for specific departments',
9 type: 'department_editor',
10 permissions: {
11 // Define permissions
12 }
13 },
14 {
15 name: 'Content Reviewer',
16 description: 'Can review and approve content before publication',
17 type: 'content_reviewer',
18 permissions: {
19 // Define permissions
20 }
21 }
22 ];
23
24 // Create roles
25 for (const role of roles) {
26 await strapi.query('plugin::users-permissions.role').create({ data: role });
27 }
28
29 console.log('Roles created successfully');
30 } catch (error) {
31 console.error('Error creating roles:', error);
32 }
33};
Setting Up Audit Logging
To improve government transparency, implement audit logging using JavaScript middleware. This code captures and logs details such as action, resource, user, IP, response time, and status for both successful operations and errors.
Security Hardening
Security isn't an afterthought; it's baked into every step. Configure security headers in your production environment using the following setup in the config/middlewares.js
file:
1// config/middlewares.js
2module.exports = [
3 'strapi::errors',
4 {
5 name: 'strapi::security',
6 config: {
7 contentSecurityPolicy: {
8 useDefaults: true,
9 directives: {
10 'connect-src': ["'self'", 'https:'],
11 'img-src': ["'self'", 'data:', 'blob:', 'https://market-assets.strapi.io'],
12 'frame-src': ["'self'"],
13 'script-src': ["'self'", "'unsafe-inline'", 'editor.unpkg.com'],
14 'frame-ancestors': ["'self'"]
15 },
16 },
17 xss: {
18 enabled: true,
19 mode: 'block'
20 },
21 hsts: {
22 enabled: true,
23 maxAge: 31536000,
24 includeSubDomains: true
25 },
26 frameguard: {
27 enabled: true,
28 action: 'sameorigin'
29 }
30 },
31 },
32 'strapi::cors',
33 'strapi::poweredBy',
34 'strapi::logger',
35 'strapi::query',
36 'strapi::body',
37 'strapi::session',
38 'strapi::favicon',
39 'strapi::public',
40 'global::audit-logger'
41];
Configuring APIs for Multichannel Delivery
Set up your API endpoints to support various government channels:
1// controllers/public-service.js
2module.exports = {
3 async find(ctx) {
4 // Detect client type (web, mobile, kiosk)
5 const clientType = ctx.request.header['x-client-type'] || 'web';
6
7 // Get base query
8 let entities = await strapi.service('api::public-service.public-service').find(ctx.query);
9
10 // Optimize response based on client type
11 switch(clientType) {
12 case 'mobile':
13 // Mobile-optimized response (lighter payload)
14 entities.results = entities.results.map(entity => ({
15 id: entity.id,
16 title: entity.title,
17 summary: entity.description.substring(0, 150) + '...',
18 department: entity.department?.name,
19 contactPhone: entity.contactInformation?.phone
20 }));
21 break;
22 case 'kiosk':
23 // Kiosk-optimized response (focused on location services)
24 entities.results = entities.results.map(entity => ({
25 id: entity.id,
26 title: entity.title,
27 description: entity.description,
28 serviceLocations: entity.serviceLocations,
29 serviceHours: entity.serviceHours
30 }));
31 break;
32 default:
33 // Web gets full response
34 break;
35 }
36
37 return entities;
38 }
39};
This foundation supports the multichannel delivery needs typical of government portals, where the same content powers websites, mobile apps, digital kiosks, and future citizen engagement platforms. By following these code examples and expanding on the core concepts, you will build a robust, secure, and compliant backend for your government information portal that can adapt to changing citizen needs while maintaining the highest standards of security and accessibility.
How to Use Next.js to Create the Frontend of Your Government Information Portal
Next.js delivers the performance, accessibility, and SEO features government portals demand. Server-side rendering boosts both search visibility and screen reader compatibility, while automatic code splitting ensures fast loading even on older devices citizens might use.
This section covers connecting your frontend to Strapi, implementing dynamic routing for services and departments, and building citizen-facing features like search and self-service forms.
Connect to Strapi and Render Secure Public Pages
Start by configuring your API client to handle authentication and error states:
1// lib/strapi.js
2const API_URL = process.env.NEXT_PUBLIC_STRAPI_API_URL || 'http://localhost:1337';
3
4export async function fetchAPI(path, options = {}) {
5 const mergedOptions = {
6 headers: {
7 'Content-Type': 'application/json',
8 },
9 ...options,
10 };
11
12 const requestUrl = `${API_URL}/api${path}`;
13
14 try {
15 const response = await fetch(requestUrl, mergedOptions);
16
17 if (!response.ok) {
18 throw new Error(`API request failed: ${response.status}`);
19 }
20
21 const data = await response.json();
22 return data;
23 } catch (error) {
24 console.error('API Error:', error);
25 throw error;
26 }
27}
Implement dynamic routing for departments and services using file-based routing. Create pages that use server-side rendering for optimal accessibility and SEO:
1// pages/department/[slug].js
2import { fetchAPI } from '../../lib/strapi';
3
4export default function DepartmentPage({ department, error }) {
5 if (error) {
6 return (
7 <div role="alert" aria-live="polite">
8 <h1>Service Temporarily Unavailable</h1>
9 <p>We're experiencing technical difficulties. Please try again later or contact us directly.</p>
10 </div>
11 );
12 }
13
14 return (
15 <main>
16 <header>
17 <h1 tabIndex="-1" id="main-heading">
18 {department.name}
19 </h1>
20 <p className="lead">{department.description}</p>
21 </header>
22
23 <section aria-labelledby="services-heading">
24 <h2 id="services-heading">Available Services</h2>
25 <ul>
26 {department.services?.data.map((service) => (
27 <li key={service.id}>
28 <a href={`/service/${service.slug}`}>
29 {service.title}
30 </a>
31 </li>
32 ))}
33 </ul>
34 </section>
35 </main>
36 );
37}
38
39export async function getServerSideProps({ params, locale }) {
40 try {
41 const department = await fetchAPI(
42 `/departments?filters[slug][$eq]=${params.slug}&populate=*&locale=${locale}`,
43 { method: 'GET' }
44 );
45
46 if (!department.data || department.data.length === 0) {
47 return { notFound: true };
48 }
49
50 return {
51 props: {
52 department: department.data[0],
53 },
54 };
55 } catch (error) {
56 return {
57 props: {
58 error: 'Failed to load department information',
59 },
60 };
61 }
62}
Configure internationalization to match your multilingual setup:
1// next.config.js
2module.exports = {
3 i18n: {
4 locales: ['en', 'es', 'fr'],
5 defaultLocale: 'en',
6 localeDetection: false,
7 },
8 async rewrites() {
9 return [
10 {
11 source: '/api/:path*',
12 destination: `${process.env.STRAPI_API_URL}/api/:path*`,
13 },
14 ];
15 },
16};
Include WCAG-compliant markup with semantic HTML, ARIA labels, and keyboard navigation. Focus management and skip links help users with assistive technologies navigate efficiently through content.
Add Search and Citizen Self-Service Features
Build a search interface that helps citizens find services and information quickly:
1// components/ServiceSearch.js
2import { useState, useEffect } from 'react';
3import { fetchAPI } from '../lib/strapi';
4
5export default function ServiceSearch() {
6 const [query, setQuery] = useState('');
7 const [results, setResults] = useState([]);
8 const [loading, setLoading] = useState(false);
9 const [error, setError] = useState(null);
10
11 const handleSearch = async (searchTerm) => {
12 if (!searchTerm.trim()) {
13 setResults([]);
14 return;
15 }
16
17 setLoading(true);
18 setError(null);
19
20 try {
21 const data = await fetchAPI(
22 `/services?filters[$or][0][title][$containsi]=${searchTerm}&filters[$or][1][description][$containsi]=${searchTerm}&populate=*`
23 );
24 setResults(data.data || []);
25 } catch (err) {
26 setError('Search is currently unavailable. Please try again later.');
27 } finally {
28 setLoading(false);
29 }
30 };
31
32 return (
33 <section aria-labelledby="search-heading">
34 <h2 id="search-heading">Search Services</h2>
35
36 <div className="search-form">
37 <label htmlFor="service-search" className="sr-only">
38 Enter keywords to search for services
39 </label>
40 <input
41 id="service-search"
42 type="search"
43 value={query}
44 onChange={(e) => setQuery(e.target.value)}
45 onKeyPress={(e) => e.key === 'Enter' && handleSearch(query)}
46 placeholder="Search services, permits, or information..."
47 aria-describedby="search-instructions"
48 />
49 <p id="search-instructions" className="sr-only">
50 Press Enter or click Search to find relevant services
51 </p>
52 <button
53 onClick={() => handleSearch(query)}
54 disabled={loading}
55 aria-describedby="search-status"
56 >
57 {loading ? 'Searching...' : 'Search'}
58 </button>
59 </div>
60
61 <div id="search-status" aria-live="polite">
62 {error && <p role="alert">{error}</p>}
63 {results.length > 0 && (
64 <p>{results.length} service{results.length !== 1 ? 's' : ''} found</p>
65 )}
66 </div>
67
68 <ul className="search-results">
69 {results.map((service) => (
70 <li key={service.id}>
71 <h3>
72 <a href={`/service/${service.slug}`}>
73 {service.title}
74 </a>
75 </h3>
76 <p>{service.description}</p>
77 </li>
78 ))}
79 </ul>
80 </section>
81 );
82}
Create secure citizen contact forms that integrate with your permissions system:
1// components/CitizenContactForm.js
2import { useState } from 'react';
3
4export default function CitizenContactForm() {
5 const [formData, setFormData] = useState({
6 name: '',
7 email: '',
8 subject: '',
9 message: '',
10 consent: false,
11 });
12 const [errors, setErrors] = useState({});
13 const [submitting, setSubmitting] = useState(false);
14 const [submitted, setSubmitted] = useState(false);
15
16 const validateForm = () => {
17 const newErrors = {};
18
19 if (!formData.name.trim()) newErrors.name = 'Name is required';
20 if (!formData.email.trim()) {
21 newErrors.email = 'Email is required';
22 } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
23 newErrors.email = 'Please enter a valid email address';
24 }
25 if (!formData.subject.trim()) newErrors.subject = 'Subject is required';
26 if (!formData.message.trim()) newErrors.message = 'Message is required';
27 if (!formData.consent) {
28 newErrors.consent = 'You must consent to data processing to submit this form';
29 }
30
31 setErrors(newErrors);
32 return Object.keys(newErrors).length === 0;
33 };
34
35 const handleSubmit = async (e) => {
36 e.preventDefault();
37
38 if (!validateForm()) return;
39
40 setSubmitting(true);
41
42 try {
43 const response = await fetch('/api/contact', {
44 method: 'POST',
45 headers: {
46 'Content-Type': 'application/json',
47 },
48 body: JSON.stringify(formData),
49 });
50
51 if (response.ok) {
52 setSubmitted(true);
53 setFormData({
54 name: '',
55 email: '',
56 subject: '',
57 message: '',
58 consent: false,
59 });
60 } else {
61 throw new Error('Submission failed');
62 }
63 } catch (error) {
64 setErrors({ submit: 'Unable to submit your message. Please try again or contact us directly.' });
65 } finally {
66 setSubmitting(false);
67 }
68 };
69
70 if (submitted) {
71 return (
72 <div role="alert" aria-live="polite">
73 <h2>Thank You</h2>
74 <p>Your message has been received. We will respond within two business days.</p>
75 </div>
76 );
77 }
78
79 return (
80 <form onSubmit={handleSubmit} noValidate>
81 <fieldset>
82 <legend>Contact Information</legend>
83
84 <div className="form-group">
85 <label htmlFor="name">
86 Full Name <span aria-label="required">*</span>
87 </label>
88 <input
89 id="name"
90 type="text"
91 value={formData.name}
92 onChange={(e) => setFormData({ ...formData, name: e.target.value })}
93 aria-invalid={errors.name ? 'true' : 'false'}
94 aria-describedby={errors.name ? 'name-error' : undefined}
95 required
96 />
97 {errors.name && (
98 <span id="name-error" role="alert" className="error">
99 {errors.name}
100 </span>
101 )}
102 </div>
103
104 <div className="form-group">
105 <label htmlFor="email">
106 Email Address <span aria-label="required">*</span>
107 </label>
108 <input
109 id="email"
110 type="email"
111 value={formData.email}
112 onChange={(e) => setFormData({ ...formData, email: e.target.value })}
113 aria-invalid={errors.email ? 'true' : 'false'}
114 aria-describedby={errors.email ? 'email-error' : undefined}
115 required
116 />
117 {errors.email && (
118 <span id="email-error" role="alert" className="error">
119 {errors.email}
120 </span>
121 )}
122 </div>
123
124 <div className="form-group">
125 <input
126 id="consent"
127 type="checkbox"
128 checked={formData.consent}
129 onChange={(e) => setFormData({ ...formData, consent: e.target.checked })}
130 aria-invalid={errors.consent ? 'true' : 'false'}
131 aria-describedby="consent-description consent-error"
132 required
133 />
134 <label htmlFor="consent">
135 I consent to the processing of my personal data for the purpose of responding to my inquiry
136 </label>
137 <p id="consent-description">
138 Your information will be used only to respond to your message and will not be shared with third parties.
139 </p>
140 {errors.consent && (
141 <span id="consent-error" role="alert" className="error">
142 {errors.consent}
143 </span>
144 )}
145 </div>
146 </fieldset>
147
148 <button type="submit" disabled={submitting}>
149 {submitting ? 'Submitting...' : 'Submit Message'}
150 </button>
151
152 {errors.submit && (
153 <div role="alert" className="error">{errors.submit}</div>
154 )}
155 </form>
156 );
157}
Create API routes that securely store form submissions in your backend with appropriate access controls. Include rate limiting, CSRF protection, and comprehensive input validation to protect against malicious submissions while ensuring all citizens can access these self-service features.
Build a Secure, Accessible, and Scalable Government Portal
You’ve just built a secure, multilingual, and compliant government portal using a headless CMS. You have also learned how to build a government information portal. Think of your architecture as a Swiss Army knife—ready to deliver content across websites, mobile apps, kiosks, and signage without constant backend updates.
Your content API scales with demand, adapting to new digital channels and citizen expectations. Agencies worldwide are using this approach to reduce overhead and deliver consistent, efficient services.
Next steps: make your portal production-ready. Migrate to Strapi Cloud, configure multi-stage content approval workflows, and integrate analytics to measure engagement. The plugin marketplace has tools to extend functionality as needed.
Citizens benefit from faster, more accessible interfaces. Your team benefits from streamlined workflows, better security, and a future-proof system that evolves with changing public service demands.