Guide: Building a Questionnaire Application
This guide walks through the complete flow of building a questionnaire application using Welshare primitives. By the end, you'll understand how to collect FHIR QuestionnaireResponses and store them securely in users' sovereign data profiles.
Overview
The typical questionnaire integration flow consists of these steps:
- Setup: Create an application and register your questionnaire
- Frontend: Build a questionnaire UI using
@welshare/questionnaire - Submission: Submit responses using either the
@welshare/reactSDK (managed wallets) or@welshare/sdk(bring your own keys)
Prerequisites
Before building your questionnaire frontend, complete these setup steps:
1. Create an Application
Register your application at wallet.welshare.app/application (or staging.wallet.welshare.app/application for testing).
See the Application Registration Guide for details.
2. Register Your Questionnaire
Create and register your FHIR Questionnaire definition with your application. Note down both the application ID and questionnaire ID - you'll need these for your frontend.
See the Questionnaire Registration Guide for details.
Building the Questionnaire Frontend
The @welshare/questionnaire package provides React components for rendering FHIR questionnaires with built-in state management.
Installation
npm install @welshare/questionnaire
# or
pnpm add @welshare/questionnaire
Setting Up the QuestionnaireProvider
The QuestionnaireProvider manages questionnaire state and responses. You can load questionnaires from the Welshare API or inline them directly.
Loading from the Welshare API
import { useState, useEffect, type ReactNode } from "react";
import {
QuestionnaireProvider as LibraryQuestionnaireProvider,
findQuestionnaireItem,
type Questionnaire,
} from "@welshare/questionnaire";
// Helper to construct the questionnaire API URL
function getQuestionnaireUrl(questionnaireId: string): string {
const baseUrl =
import.meta.env.VITE_API_BASE_URL || "https://wallet.welshare.app";
return `${baseUrl}/api/questionnaire/${questionnaireId}`;
}
interface QuestionnaireProviderProps {
children: ReactNode;
}
export const QuestionnaireProvider = ({
children,
}: QuestionnaireProviderProps) => {
const [questionnaire, setQuestionnaire] = useState<Questionnaire | null>(
null
);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadQuestionnaire = async () => {
try {
const questionnaireUrl = getQuestionnaireUrl(
import.meta.env.VITE_QUESTIONNAIRE_ID as string
);
const res = await fetch(questionnaireUrl);
if (!res.ok) throw new Error("Failed to load questionnaire");
const data = await res.json();
setQuestionnaire(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setIsLoading(false);
}
};
loadQuestionnaire();
}, []);
if (isLoading) return <div className="loading">Loading questionnaire...</div>;
if (error) return <div className="error">Error: {error}</div>;
if (!questionnaire) return <div className="error">No questionnaire data</div>;
return (
<LibraryQuestionnaireProvider
questionnaire={questionnaire}
questionnaireId={import.meta.env.VITE_QUESTIONNAIRE_ID}
useNestedStructure={true}
>
{children}
</LibraryQuestionnaireProvider>
);
};
Inlining a Questionnaire
For simpler setups, you can inline the questionnaire definition directly:
import { QuestionnaireProvider } from "@welshare/questionnaire";
const myQuestionnaire = {
resourceType: "Questionnaire",
id: "my-questionnaire",
status: "active",
title: "My Survey",
item: [
{
linkId: "q1",
text: "How are you feeling today?",
type: "choice",
required: true,
answerOption: [
{ valueCoding: { code: "good", display: "Good" } },
{ valueCoding: { code: "okay", display: "Okay" } },
{ valueCoding: { code: "bad", display: "Bad" } },
],
},
],
};
export const App = () => (
<QuestionnaireProvider
questionnaire={myQuestionnaire}
questionnaireId="your-registered-questionnaire-id"
>
<YourQuestionnaireComponents />
</QuestionnaireProvider>
);
Rendering Questions
Use the QuestionRenderer component to render individual questionnaire items. The component automatically handles different question types (choice, text, integer, decimal, boolean).
Basic Question Rendering
import {
useQuestionnaire,
QuestionRenderer,
type QuestionnaireItem,
} from "@welshare/questionnaire";
// Import the component styles
import "@welshare/questionnaire/tokens.css";
import "@welshare/questionnaire/styles.css";
const QuestionnairePage = () => {
const { questionnaire } = useQuestionnaire();
const renderQuestions = (items: QuestionnaireItem[]) => {
return items.map((item) => {
if (item.type === "group") {
return (
<div key={item.linkId} className="question-group">
<h3>{item.text}</h3>
{item.item && renderQuestions(item.item)}
</div>
);
}
return <QuestionRenderer key={item.linkId} item={item} />;
});
};
if (!questionnaire?.item) return null;
return (
<div className="questionnaire">
<h1>{questionnaire.title}</h1>
{renderQuestions(questionnaire.item)}
</div>
);
};
Customizing Radio and Checkbox Inputs
You can provide custom renderers for radio buttons and checkboxes to match your application's design system:
import {
QuestionRenderer,
type RadioInputProps,
type CheckboxInputProps,
type QuestionnaireItem,
} from "@welshare/questionnaire";
/**
* Custom radio button renderer matching your app's design system
*/
const CustomRadioInput = (props: RadioInputProps) => {
return (
<label className={`choice-option ${props.checked ? "selected" : ""}`}>
<input
type="radio"
name={props.linkId}
value={props.valueCoding?.code}
checked={props.checked}
onChange={props.onChange}
/>
<span className="choice-label">{props.label}</span>
</label>
);
};
/**
* Custom checkbox renderer matching your app's design system
*/
const CustomCheckboxInput = (props: CheckboxInputProps) => {
return (
<label className={`checkbox-option ${props.checked ? "selected" : ""}`}>
<input
type="checkbox"
checked={props.checked}
disabled={props.disabled}
onChange={props.onChange}
/>
<span className="checkbox-label">{props.label}</span>
</label>
);
};
const renderQuestions = (items: QuestionnaireItem[]) => {
return items.map((item) => {
if (item.type === "group") {
return (
<div key={item.linkId} className="question-group">
<h3>{item.text}</h3>
{item.item && renderQuestions(item.item)}
</div>
);
}
return (
<QuestionRenderer
key={item.linkId}
item={item}
renderRadioInput={CustomRadioInput}
renderCheckboxInput={CustomCheckboxInput}
/>
);
});
};
Accessing Response State
Use the useQuestionnaire hook to access and manipulate the response state:
import { useQuestionnaire } from "@welshare/questionnaire";
const SubmitButton = () => {
const {
response, // Current QuestionnaireResponse object
isPageValid, // Check if required questions are answered
getAnswer, // Get answer for a specific linkId
updateAnswer, // Programmatically update an answer
} = useQuestionnaire();
const handleSubmit = () => {
console.log("Submitting response:", response);
// Submit the response...
};
return <button onClick={handleSubmit}>Submit</button>;
};
Submitting Responses
There are two approaches to submit questionnaire responses to Welshare:
@welshare/react: Managed approach with built-in wallet UI (recommended for most apps)@welshare/sdk: Direct submission for apps managing their own keys
Option A: Using @welshare/react (Recommended)
The @welshare/react package provides a managed wallet experience through an iframe-based frontend. Users can create wallets and submit data without your application handling cryptographic keys. To control the environment that you're submitting to, use the apiBaseUrl parameter (production: https://wallet.welshare.app, staging: https://staging.wallet.welshare.app); it defaults to the production environment.
Installation
npm install @welshare/react
# or
pnpm add @welshare/react
Implementation
import { Schemas, useWelshare, WelshareLogo } from "@welshare/react";
import { useQuestionnaire } from "@welshare/questionnaire";
import type { SubmissionPayload } from "@welshare/react/types";
const SubmissionPanel = () => {
const { response } = useQuestionnaire();
const [submitted, setSubmitted] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { isConnected, openWallet, submitData, isSubmitting, storageKey } =
useWelshare({
applicationId: import.meta.env.VITE_APP_ID,
apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
callbacks: {
onError: (error: unknown) => {
console.error("Submission error:", error);
const message =
error instanceof Error ? error.message : String(error);
setErrorMessage(message);
setSubmitted(false);
},
onUploaded: (payload: SubmissionPayload<unknown>) => {
console.log("Submission successful:", payload);
setSubmitted(true);
},
},
});
const handleSubmit = async () => {
if (!isConnected) {
alert("Please connect your wallet first");
return;
}
if (!response) {
alert("No questionnaire response to submit");
return;
}
try {
// Submit the QuestionnaireResponse to Welshare
submitData(Schemas.QuestionnaireResponse, response);
} catch (error) {
console.error("Submission error:", error);
alert("Failed to submit questionnaire. Please try again.");
}
};
// Helper to display truncated storage key DID for display reasons
const truncateDid = (did: string, start = 6, end = 7) =>
`${did.slice(0, start)}...${did.slice(-end)}`;
return (
<div className="submission-panel">
{isConnected && storageKey && (
<div className="connection-status">
Connected: <code>{truncateDid(storageKey, 6, 7)}</code>
</div>
)}
{!isConnected ? (
<button className="btn btn-primary" onClick={openWallet}>
<WelshareLogo /> Connect & Save to Your Health Profile
</button>
) : (
<button
className="btn btn-primary"
onClick={handleSubmit}
disabled={isSubmitting}
>
<WelshareLogo />
{isSubmitting ? "Saving..." : "Save to Your Health Profile"}
</button>
)}
{submitted && <p className="success">Response saved successfully!</p>}
{errorMessage && <p className="error">{errorMessage}</p>}
</div>
);
};
Option B: Using @welshare/sdk (Bring Your Own Keys)
For applications that manage their own wallet infrastructure (e.g., using Privy, Web3Modal, or similar), you can derive storage keys and submit directly using the SDK.
Installation
npm install @welshare/sdk
# or
pnpm add @welshare/sdk
Step 1: Derive a Storage Key
The storage key is derived from a user's wallet signature. This example uses Privy, but any EVM wallet signing capability works.
import { useState } from "react";
import {
usePrivy,
useSignTypedData,
type SignTypedDataParams,
} from "@privy-io/react-auth";
import { deriveStorageKeypair, type SessionKeyData } from "@welshare/sdk";
export const useStorageKey = () => {
const { ready, user } = usePrivy();
const { signTypedData } = useSignTypedData();
const [storageKey, setStorageKey] = useState<SessionKeyData>();
const makeStorageKey = async () => {
if (!ready || !user?.wallet?.address) {
console.error("Wallet not available");
return;
}
// Derive a storage keypair by signing a typed message
const storageKeyData = await deriveStorageKeypair(
async (params: Record<string, unknown>) => {
const { signature } = await signTypedData(
params as SignTypedDataParams
);
return signature as `0x${string}`;
},
user.wallet.address as `0x${string}`
);
setStorageKey(storageKeyData);
};
return { storageKey, makeStorageKey };
};
Step 2: Submit Data Directly
With a derived storage key, submit data directly to Nillion's data collections that are maintained and operated by Welshare. Under the hood, the sdk takes care of fetching a delegation token (it allows your users to write into the shared collections), verifying the submission and checking that the application and data submission payload is valid.
import { useState } from "react";
import {
QuestionnaireResponseSchema,
resolveEnvironment,
WelshareApi,
type SessionKeyData,
type WELSHARE_API_ENVIRONMENT,
} from "@welshare/sdk";
import { useQuestionnaire } from "@welshare/questionnaire";
export const useDirectSubmission = (storageKey: SessionKeyData | undefined) => {
const { response } = useQuestionnaire();
const [isSubmitting, setIsSubmitting] = useState(false);
const submitForm = async () => {
if (!storageKey) {
console.error("Storage key required");
return;
}
if (!response) {
console.error("No response to submit");
return;
}
setIsSubmitting(true);
const submissionPayload = {
applicationId: import.meta.env.VITE_APP_ID,
timestamp: new Date().toISOString(),
schemaId: QuestionnaireResponseSchema.schemaUid,
submission: response,
};
try {
const environment = resolveEnvironment(
(import.meta.env
.VITE_ENVIRONMENT as keyof typeof WELSHARE_API_ENVIRONMENT) ||
"staging"
);
const apiResponse = await WelshareApi.submitData(
storageKey.sessionKeyPair,
submissionPayload,
environment
);
console.log("Submission successful:", apiResponse);
} catch (error) {
console.error("Failed to submit:", error);
} finally {
setIsSubmitting(false);
}
};
return { isSubmitting, submitForm };
};
Example with Privy
import { usePrivy } from "@privy-io/react-auth";
import { useStorageKey } from "./hooks/use-storage-key";
import { useDirectSubmission } from "./hooks/use-direct-submission";
const DirectSubmissionPanel = () => {
const { authenticated, login } = usePrivy();
const { storageKey, makeStorageKey } = useStorageKey();
const { isSubmitting, submitForm } = useDirectSubmission(storageKey);
if (!authenticated) {
return <button onClick={login}>Connect Wallet</button>;
}
if (!storageKey) {
return <button onClick={makeStorageKey}>Derive Storage Key</button>;
}
return (
<button onClick={submitForm} disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit Response"}
</button>
);
};
Environment Configuration
Configure your application with these environment variables:
# Your registered application ID
VITE_APP_ID=your-application-id
# Your registered questionnaire ID
VITE_QUESTIONNAIRE_ID=your-questionnaire-id
# API base URL (optional, defaults to production)
VITE_API_BASE_URL=https://wallet.welshare.app
Data Flow Summary
Here's how data flows through the system:
- User fills out the questionnaire in your frontend
- @welshare/questionnaire manages state and builds a
QuestionnaireResponse - User connects their wallet (either via
@welshare/reactor your own wallet integration) - Storage key is derived from the user's wallet signature
- Response is encrypted client-side and stored in the user's sovereign data profile
- Application (you) can later query responses for analysis
Users always own their data. The encryption keys are derived from their wallet signature, so only they can access their submitted data. Upon submission they automatically authorize the welshare builder identity to read the information, too, but technically they can revoke that ACL entry at any time by interacting with the Nillion cluster nodes at any time.
Next Steps
- Explore the Questionnaire Components documentation for detailed component APIs
- Learn about Key Management to understand how user data is protected
- Review the React SDK documentation for additional integration options
Reference Implementations
- demo-saq - Seattle Angina Questionnaire with direct SDK submission
- diabetesdao-findrisc - Finnish Diabetes Risk Score with
@welshare/react