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:
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:
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.
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.
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.
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.
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};
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 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];
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.
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.
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.
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.
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.